feat(pc-decorate): PC端楼层装修组件体系规范默认行高与各楼层组件重构

This commit is contained in:
pikachu1995@126.com
2026-10-29 17:06:08 +08:00
parent 5973d43b9d
commit 8739811b04
45 changed files with 7340 additions and 1856 deletions

View File

@@ -214,7 +214,7 @@ if (props.options.showIndicator === undefined) props.options.showIndicator = tru
if (!props.options.effect) props.options.effect = 'fade';
if (!props.options.fitMode) props.options.fitMode = 'contain';
if (!props.options.aspectRatio) props.options.aspectRatio = '2.5:1';
if (props.options.height === undefined) props.options.height = 380;
if (props.options.height === undefined) props.options.height = 420;
if (!Array.isArray(props.options.list)) {
props.options.list = [
{ title: '出游囤好货', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png', url: '/goodsList?seckill=1' },

View File

@@ -6,7 +6,7 @@
<div class="title-with-tip">
<span>内容设置</span>
<el-tooltip
content="设置类目弹层的文字颜色及显示字号"
content="设置商品分类的文字颜色及显示字号"
placement="top"
>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
@@ -69,14 +69,26 @@
<el-input
v-model="options.activeColor"
size="small"
placeholder="#ea580c"
placeholder="#409EFF"
class="color-input"
/>
</div>
</div>
<!-- 分类项列表 -->
<div class="categories-sub-section mt-20">
<!-- 数据来源 -->
<div class="form-row flex-col mt-16">
<span class="row-label">数据来源</span>
<el-radio-group v-model="options.source" size="small">
<el-radio-button label="system">系统分类 (自动)</el-radio-button>
<el-radio-button label="custom">手动自定义</el-radio-button>
</el-radio-group>
<span class="form-tip mt-6" v-if="options.source !== 'custom'">
已启用系统商品分类,前后台自动拉取商城最新分类及二级、三级悬浮大菜单。
</span>
</div>
<!-- 分类项列表 (仅自定义模式可见) -->
<div class="categories-sub-section mt-20" v-if="options.source === 'custom'">
<div class="sub-header flex-between">
<span class="sub-title">分类条目</span>
<span class="sub-count">共 {{ (options.categories || []).length }} 项</span>
@@ -153,6 +165,7 @@ const props = defineProps({
});
// 初始化默认字段
if (!props.options.source) props.options.source = 'system';
if (!props.options.title) props.options.title = '分类';
if (!props.options.textColor) props.options.textColor = '#111927';
if (!props.options.fontSize) props.options.fontSize = 14;

View File

@@ -3,46 +3,142 @@
<!-- 1. 标题区设置 -->
<div class="panel-section">
<div class="section-title">标题区设置</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">展示标题区</span>
<el-switch v-model="options.showTitle" size="small" />
</div>
<template v-if="options.showTitle !== false">
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:场景频道" />
<el-input v-model="options.title" size="small" placeholder="如:场景频道" clearable />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:主视觉 + 商品组合楼层" />
<el-input v-model="options.subtitle" size="small" placeholder="如:主视觉 + 商品组合楼层" clearable />
</div>
<div class="form-item">
<span class="item-label">“更多”跳转链接 (选填)</span>
<div class="link-picker-wrap">
<el-input
v-model="options.moreUrl"
size="small"
placeholder="默认跟随主视觉链接,如 /goods"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectMoreLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.moreUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.moreUrl">{{ options.moreUrl }}</span>
</div>
</div>
</template>
</div>
<!-- 2. 主视觉设置 (Hero Section) -->
<!-- 2. 场景主视觉 (Hero Section) -->
<div class="panel-section">
<div class="section-title">场景主视觉</div>
<!-- 主视觉图片 -->
<div class="form-item">
<span class="item-label">主视觉图片</span>
<el-input v-model="options.heroImage" size="small" placeholder="建议尺寸: 520x560px" clearable />
<span class="item-label">主视觉图片 (建议尺寸 520x560px)</span>
<div class="picker-flex-wrap">
<div
class="thumb-preview-box"
@click="handleSelectHeroImage"
:title="options.heroImage ? '点击更换主视觉图片' : '点击选择图片'"
>
<template v-if="options.heroImage">
<img :src="options.heroImage" alt="主视觉" class="preview-img" />
<span class="thumb-clear-btn" @click.stop="options.heroImage = ''" title="清除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="thumb-empty-icon"><Picture /></el-icon>
<span class="thumb-empty-tip">选图</span>
</template>
</div>
<div class="input-with-action flex-1">
<el-input
v-model="options.heroImage"
size="small"
placeholder="请输入或选择主视觉图片"
clearable
/>
<el-button
size="small"
class="action-picker-btn"
@click="handleSelectHeroImage"
>
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
</div>
</div>
<div class="form-item">
<div class="form-item mt-10">
<span class="item-label">主视觉大标题</span>
<el-input v-model="options.heroTitle" size="small" placeholder="如:品质生活馆" />
<el-input v-model="options.heroTitle" size="small" placeholder="如:品质生活馆" clearable />
</div>
<div class="form-item">
<span class="item-label">主视觉副标题</span>
<el-input v-model="options.heroSubtitle" size="small" placeholder="如:精选场景好物,一站购齐" />
<el-input v-model="options.heroSubtitle" size="small" placeholder="如:精选场景好物,一站购齐" clearable />
</div>
<div class="form-item">
<span class="item-label">行动按钮文案</span>
<el-input v-model="options.heroButtonText" size="small" placeholder="如:进入频道" />
<el-input v-model="options.heroButtonText" size="small" placeholder="如:进入频道" clearable />
</div>
<!-- 按钮跳转链接 -->
<div class="form-item">
<span class="item-label">按钮跳转链接</span>
<el-input v-model="options.heroHref" size="small" placeholder="如:/goods" />
<div class="link-picker-wrap">
<el-input
v-model="options.heroHref"
size="small"
placeholder="如 /goods 或活动页面链接"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectHeroLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.heroHref">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.heroHref">{{ options.heroHref }}</span>
</div>
</div>
<div class="form-item">
<div class="form-item flex-row mt-10">
<span class="item-label">主视觉视觉风格</span>
<el-radio-group v-model="options.heroStyle" size="small">
<el-radio-button label="gradient">主题渐变</el-radio-button>
@@ -54,38 +150,113 @@
<!-- 3. 活动入口 (Promos) -->
<div class="panel-section">
<div class="section-title flex-between">
<span>活动入口 ({{ options.promos?.length || 0 }} 个)</span>
<span>专属活动入口 ({{ options.promos?.length || 0 }} 个)</span>
<el-button link type="primary" size="small" @click="handleAddPromo">
<el-icon><Plus /></el-icon> 添加活动
</el-button>
</div>
<div class="promos-list-editor">
<div class="promos-list-editor" v-if="Array.isArray(options.promos) && options.promos.length">
<div v-for="(p, pIdx) in options.promos" :key="pIdx" class="promo-editor-card">
<div class="promo-row-1">
<el-input v-model="p.tag" size="small" placeholder="标签(限时/新品)" style="width: 100px" />
<el-input v-model="p.title" size="small" placeholder="活动主标题" style="flex: 1" />
<el-button link type="danger" size="small" @click="options.promos.splice(pIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
<!-- 顶部标签与排序按钮 -->
<div class="promo-card-header">
<span class="promo-idx-badge">入口 {{ pIdx + 1 }}{{ p.title ? ` · ${p.title}` : '' }}</span>
<div class="promo-actions">
<el-button
link
size="small"
:disabled="pIdx === 0"
@click="movePromo(pIdx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="pIdx === options.promos.length - 1"
@click="movePromo(pIdx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
link
type="danger"
size="small"
@click="options.promos.splice(pIdx, 1)"
title="删除入口"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="promo-row-2">
<el-input v-model="p.desc" size="small" placeholder="活动描述(如: 领券最高减100)" />
<!-- 属性输入 -->
<div class="promo-row-1">
<div class="sub-col" style="width: 100px;">
<span class="micro-label">角标标签</span>
<el-input v-model="p.tag" size="small" placeholder="限时/新品" clearable />
</div>
<div class="sub-col flex-1">
<span class="micro-label">活动标题</span>
<el-input v-model="p.title" size="small" placeholder="如:大牌补贴" clearable />
</div>
</div>
<div class="promo-row-2 mt-6">
<span class="micro-label">活动描述</span>
<el-input v-model="p.desc" size="small" placeholder="如:领券最高减100" clearable />
</div>
<!-- 活动链接选择器 -->
<div class="promo-row-3 mt-6">
<span class="micro-label">跳转链接</span>
<div class="link-picker-wrap">
<el-input
v-model="p.url"
size="small"
placeholder="如 /goods 或活动专题页"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectPromoLink(p)"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="p.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="p.url">{{ p.url }}</span>
</div>
</div>
</div>
</div>
<div class="empty-tip" v-else>
暂无活动入口,点击右上角“添加活动”
</div>
</div>
<!-- 4. 场景商品选择 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>场景商品位 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showGoodsModal = true">
选择真实商品
</el-button>
<div style="display: flex; gap: 8px;">
<el-button v-if="options.goods?.length" link type="danger" size="small" @click="options.goods = []">清空</el-button>
<el-button type="primary" size="small" @click="handleOpenGoodsPicker">选择商品</el-button>
</div>
</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">展示商品数量</span>
<el-input-number v-model="options.goodsCount" :min="1" :max="8" size="small" controls-position="right" />
</div>
@@ -104,34 +275,32 @@
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showGoodsModal" title="选择场景商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 商品选择弹窗 (兜底) -->
<el-dialog
v-if="!openGoodsPicker && showGoodsModal"
v-model="showGoodsModal"
title="从商品库选择商品"
width="1160px"
append-to-body
destroy-on-close
>
<goods-dialog
:selectedWay="tempSelectedGoods"
@selected="(val) => { tempSelectedGoods = val; }"
/>
<template #footer>
<el-button @click="showGoodsModal = false">完成</el-button>
<el-button @click="showGoodsModal = false">取消</el-button>
<el-button type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Plus, Delete, Check } from '@element-plus/icons-vue';
import { ref, inject } from 'vue';
import GoodsDialog from '@/components/lili-dialog/goods-dialog.vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
@@ -146,7 +315,14 @@ const props = defineProps({
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const showGoodsModal = ref(false);
const tempSelectedGoods = ref([]);
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
const openGoodsPicker = inject('openGoodsPicker', null);
// 默认值兜底
if (props.options.showTitle === undefined) props.options.showTitle = true;
if (!props.options.title) props.options.title = '场景频道';
if (!props.options.subtitle) props.options.subtitle = '主视觉 + 商品组合楼层';
@@ -174,15 +350,70 @@ if (!props.options.goods) {
];
}
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' }
];
// 1. 选择主视觉图片
function handleSelectHeroImage() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) props.options.heroImage = url;
});
} else {
ElMessageBox.prompt('请输入主视觉图片 URL 地址:', '选择主视觉图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.heroImage || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.heroImage = value.trim();
})
.catch(() => {});
}
}
// 2. 选择主视觉按钮链接
function handleSelectHeroLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.heroHref = url;
});
} else {
ElMessageBox.prompt('请输入主视觉跳转链接(如 /goods 或活动网址):', '设置主视觉链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.heroHref || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.heroHref = value.trim();
})
.catch(() => {});
}
}
// 3. 选择“探索更多”链接
function handleSelectMoreLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.moreUrl = url;
});
} else {
ElMessageBox.prompt('请输入“更多”跳转链接:', '设置更多跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.moreUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.moreUrl = value.trim();
})
.catch(() => {});
}
}
// 4. 活动入口增删与排序
function handleAddPromo() {
if (!Array.isArray(props.options.promos)) props.options.promos = [];
props.options.promos.push({
@@ -193,17 +424,62 @@ function handleAddPromo() {
});
}
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
function movePromo(index, step) {
const target = index + step;
if (target < 0 || target >= props.options.promos.length) return;
const item = props.options.promos.splice(index, 1)[0];
props.options.promos.splice(target, 0, item);
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
// 5. 选择活动入口链接
function handleSelectPromoLink(p) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
if (url) p.url = url;
if (!p.title && title) p.title = title;
});
} else {
props.options.goods.push({ ...item });
ElMessageBox.prompt('请输入活动入口跳转链接:', '设置活动链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: p.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
p.url = value.trim();
})
.catch(() => {});
}
}
// 6. 商品选择
function handleOpenGoodsPicker() {
if (typeof openGoodsPicker === 'function') {
openGoodsPicker((selectedGoods) => {
applySelectedGoods(selectedGoods);
}, props.options.goods || []);
} else {
tempSelectedGoods.value = Array.isArray(props.options.goods) ? [...props.options.goods] : [];
showGoodsModal.value = true;
}
}
function confirmGoodsSelected() {
applySelectedGoods(tempSelectedGoods.value);
showGoodsModal.value = false;
}
function applySelectedGoods(selectedGoods) {
if (Array.isArray(selectedGoods)) {
props.options.goods = selectedGoods.map(g => ({
id: g.id || g.skuId || g.goodsId,
goodsId: g.goodsId,
goodsName: g.goodsName || g.name,
price: g.price,
originalPrice: g.originalPrice || g.price,
thumbnail: g.thumbnail || g.pic || defaultThumb
}));
}
}
</script>
@@ -217,6 +493,12 @@ function toggleSelect(item) {
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-size: 13px;
font-weight: 600;
@@ -232,38 +514,223 @@ function toggleSelect(item) {
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-direction: column;
gap: 5px;
margin-bottom: 10px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
font-weight: 500;
white-space: nowrap;
}
}
}
.mt-6 {
margin-top: 6px;
}
.mt-10 {
margin-top: 10px;
}
.mr-4 {
margin-right: 4px;
}
.flex-1 {
flex: 1;
}
.micro-label {
font-size: 11px;
color: #64748b;
margin-bottom: 2px;
display: block;
}
.picker-flex-wrap {
display: flex;
align-items: center;
gap: 8px;
.thumb-preview-box {
position: relative;
width: 58px;
height: 40px;
border-radius: 4px;
border: 1px dashed #cbd5e1;
background: #ffffff;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s;
&:hover {
border-color: #409eff;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-empty-icon {
font-size: 15px;
color: #94a3b8;
}
.thumb-empty-tip {
font-size: 10px;
color: #94a3b8;
line-height: 1;
margin-top: 2px;
}
.thumb-clear-btn {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.65);
color: #ffffff;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 2px 8px;
margin-top: 2px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.promos-list-editor {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
.promo-editor-card {
background: #f8fafc;
padding: 8px;
border-radius: 6px;
padding: 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
.promo-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #edf2f7;
.promo-idx-badge {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.promo-actions {
display: flex;
align-items: center;
gap: 2px;
}
}
.promo-row-1 {
display: flex;
gap: 6px;
margin-bottom: 6px;
gap: 8px;
}
}
}
.empty-tip {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 16px 0;
}
.goods-list-preview {
max-height: 200px;
overflow-y: auto;
@@ -310,59 +777,4 @@ function toggleSelect(item) {
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #4f46e5;
}
&.selected {
background: #eef2ff;
border-color: #4f46e5;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #4f46e5;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

@@ -5,26 +5,58 @@
<div class="section-title">宫格标题设置</div>
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:爆款热卖榜" />
<el-input v-model="options.title" size="small" placeholder="如:爆款热卖榜" clearable />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:近30天爆卖好物精选" />
<el-input v-model="options.subtitle" size="small" placeholder="如:近30天爆卖好物精选" clearable />
</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">展示查看更多</span>
<el-switch v-model="options.showMoreLink" size="small" />
</div>
<div class="form-item" v-if="options.showMoreLink !== false">
<span class="item-label">更多文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:查看全部" />
</div>
<template v-if="options.showMoreLink !== false">
<div class="form-item">
<span class="item-label">更多文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:查看全部" clearable />
</div>
<div class="form-item">
<span class="item-label">更多跳转链接</span>
<div class="link-picker-wrap">
<el-input
v-model="options.moreUrl"
size="small"
placeholder="如:/goodsList 或专题页面"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectMoreLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.moreUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.moreUrl">{{ options.moreUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.moreUrl = ''"><Close /></el-icon>
</div>
</div>
</template>
</div>
<!-- 2. 宫格排版设置 (网格与列数) -->
<div class="panel-section">
<div class="section-title">宫格排版与线条</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">一行展示几个 (列数)</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="4">4 列</el-radio-button>
@@ -32,7 +64,7 @@
<el-radio-button :label="6">6 列</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">矩阵实线网格分割线</span>
<el-switch v-model="options.showBorder" size="small" />
</div>
@@ -42,54 +74,119 @@
<div class="panel-section">
<div class="section-title flex-between">
<span>宫格商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showModal = true">
选择真实商品
</el-button>
<div class="btn-group">
<el-button v-if="options.goods?.length" link type="danger" size="small" @click="options.goods = []">
清空
</el-button>
<el-button type="primary" size="small" @click="handleOpenGoodsPicker">
选择商品
</el-button>
<el-button size="small" @click="handleAddCustomGoods">
<el-icon class="mr-4"><Plus /></el-icon> 自定义加品
</el-button>
</div>
</div>
<div class="goods-grid-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="grid-item-row">
<span class="idx-badge">{{ idx + 1 }}</span>
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
<div class="thumb-wrap-mini" @click="handleSelectGoodsThumb(g)" title="点击更换商品图片">
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="thumb-tip">换图</div>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
<div class="g-info">
<el-input
v-model="g.goodsName"
size="small"
placeholder="商品名称"
class="g-name-input"
/>
<div class="g-meta-row">
<div class="price-input-wrap">
<span class="price-sym">¥</span>
<el-input v-model="g.price" size="small" placeholder="价格" class="price-mini-input" />
</div>
<el-button
link
type="primary"
size="small"
@click="handleSelectGoodsLink(g)"
:title="g.url || '设置跳转链接'"
>
<el-icon><Link /></el-icon>
<span style="font-size: 11px; margin-left: 2px;">{{ g.url ? '已配链接' : '选链接' }}</span>
</el-button>
</div>
<div class="item-url-badge" v-if="g.url">
<span class="url-label">🔗</span>
<span class="url-text" :title="g.url">{{ g.url }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="g.url = ''"><Close /></el-icon>
</div>
</div>
<div class="action-btn-col">
<el-button
link
size="small"
:disabled="idx === 0"
@click="handleMoveGoods(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="idx === options.goods.length - 1"
@click="handleMoveGoods(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<div class="goods-empty-tip" v-else>
<span>暂未选择商品,前台将展示推荐占位</span>
<div class="empty-btn-row">
<el-button type="primary" link size="small" @click="handleOpenGoodsPicker">
+ 从商品库选择
</el-button>
<el-button type="primary" link size="small" @click="handleAddCustomGoods">
+ 自定义加品
</el-button>
</div>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showModal" title="选择宫格商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 兜底商品选择弹窗(当未注入全局 openGoodsPicker 时生效) -->
<el-dialog
v-if="!openGoodsPicker && fallbackModal"
v-model="fallbackModal"
title="从商品库选择商品"
width="1160px"
append-to-body
destroy-on-close
>
<goods-dialog
:selectedWay="tempSelectedGoods"
@selected="(val) => { tempSelectedGoods = val; }"
/>
<template #footer>
<el-button @click="showModal = false">完成</el-button>
<el-button @click="fallbackModal = false">取消</el-button>
<el-button type="primary" @click="confirmFallbackSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Delete, Check } from '@element-plus/icons-vue';
import { ref, inject } from 'vue';
import { Delete, Link, Picture, Plus, Top, Bottom, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import GoodsDialog from '@/components/lili-dialog/goods-dialog.vue';
const props = defineProps({
component: {
@@ -103,51 +200,139 @@ const props = defineProps({
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const showModal = ref(false);
if (!props.options.title) props.options.title = '爆款热卖榜';
if (!props.options.subtitle) props.options.subtitle = '近30天爆卖好物精选';
if (props.options.showMoreLink === undefined) props.options.showMoreLink = true;
if (!props.options.moreText) props.options.moreText = '查看全部';
if (!props.options.columns) props.options.columns = 5;
if (props.options.showBorder === undefined) props.options.showBorder = true;
if (!props.options.goods) {
props.options.goods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '10', goodsName: '15.6英寸 4K超高清便携式显示器 Type-C一线直连高色域', price: '1299.00', originalPrice: '1699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' }
];
}
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
const openGoodsPicker = inject('openGoodsPicker', null);
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' }
];
const fallbackModal = ref(false);
const tempSelectedGoods = ref([]);
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
// 1. 选择更多链接
function handleSelectMoreLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.moreUrl = url;
});
} else {
props.options.goods.push({ ...item });
ElMessageBox.prompt('请输入“查看更多”跳转链接:', '设置更多链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.moreUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.moreUrl = value.trim();
})
.catch(() => {});
}
}
// 2. 更换商品图片
function handleSelectGoodsThumb(g) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) g.thumbnail = url;
});
} else {
ElMessageBox.prompt('请输入商品图片 URL 地址:', '更换商品图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: g.thumbnail || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
g.thumbnail = value.trim();
})
.catch(() => {});
}
}
// 3. 配置商品专属跳转链接
function handleSelectGoodsLink(g) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
if (url) g.url = url;
if (!g.goodsName && title) g.goodsName = title;
});
} else {
ElMessageBox.prompt('请输入商品跳转链接(如 /goodsDetail?goodsId=xxx 或外部网址):', '设置商品链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: g.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
g.url = value.trim();
})
.catch(() => {});
}
}
// 4. 自定义加品
function handleAddCustomGoods() {
if (!Array.isArray(props.options.goods)) {
props.options.goods = [];
}
const nextIdx = props.options.goods.length + 1;
const newItem = {
id: `custom_${Date.now()}_${nextIdx}`,
goodsName: `爆款精选好物 ${nextIdx}`,
price: '99.00',
originalPrice: '199.00',
thumbnail: defaultThumb,
url: ''
};
props.options.goods.push(newItem);
if (openImagePicker) {
openImagePicker((url) => {
if (url) newItem.thumbnail = url;
});
}
}
// 5. 排序调整
function handleMoveGoods(idx, direction) {
const list = props.options.goods;
const target = idx + direction;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
// 6. 从商品库选择
function handleOpenGoodsPicker() {
if (typeof openGoodsPicker === 'function') {
openGoodsPicker((selectedGoods) => {
applySelectedGoods(selectedGoods);
}, props.options.goods || []);
} else {
tempSelectedGoods.value = Array.isArray(props.options.goods) ? [...props.options.goods] : [];
fallbackModal.value = true;
}
}
function confirmFallbackSelected() {
applySelectedGoods(tempSelectedGoods.value);
fallbackModal.value = false;
}
function applySelectedGoods(selectedGoods) {
if (Array.isArray(selectedGoods)) {
props.options.goods = selectedGoods.map(g => ({
id: g.id || g.skuId || g.goodsId,
goodsId: g.goodsId,
goodsName: g.goodsName || g.name,
price: g.price,
originalPrice: g.originalPrice || g.price,
thumbnail: g.thumbnail || g.pic || defaultThumb,
url: g.url || ''
}));
}
}
</script>
@@ -176,125 +361,278 @@ function toggleSelect(item) {
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-direction: column;
gap: 5px;
margin-bottom: 10px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
font-weight: 500;
}
}
}
.mr-4 {
margin-right: 4px;
}
.btn-group {
display: flex;
align-items: center;
gap: 6px;
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.url-clear-icon {
font-size: 12px;
color: #94a3b8;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: #ef4444;
}
}
}
.goods-grid-preview {
max-height: 240px;
max-height: 380px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
gap: 8px;
.grid-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
padding: 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
transition: background 0.15s ease;
.idx-badge {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
width: 16px;
text-align: center;
&:hover {
background: #f1f5f9;
}
.g-thumb {
width: 36px;
height: 36px;
.idx-badge {
width: 18px;
height: 18px;
background: #e2e8f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: #475569;
flex-shrink: 0;
}
.thumb-wrap-mini {
position: relative;
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
overflow: hidden;
flex-shrink: 0;
cursor: pointer;
border: 1px solid #cbd5e1;
.g-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-tip {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.7);
color: #ffffff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .thumb-tip {
opacity: 1;
}
}
.g-info {
flex: 1;
overflow: hidden;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.g-name-input {
:deep(.el-input__inner) {
font-size: 12px;
padding: 0 6px;
height: 24px;
}
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
.g-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
.price-input-wrap {
display: flex;
align-items: center;
gap: 2px;
font-size: 11px;
color: #ef4444;
font-weight: 600;
.price-mini-input {
width: 70px;
:deep(.el-input__inner) {
font-size: 11px;
padding: 0 4px;
height: 22px;
color: #ef4444;
font-weight: 600;
}
}
}
}
.item-url-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 3px;
padding: 2px 6px;
color: #2563eb;
.url-label {
font-size: 10px;
flex-shrink: 0;
}
.url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.url-clear-icon {
cursor: pointer;
flex-shrink: 0;
font-size: 11px;
color: #94a3b8;
&:hover {
color: #ef4444;
}
}
}
}
.action-btn-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
.el-button {
margin: 0;
padding: 2px;
height: auto;
font-size: 12px;
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
.goods-empty-tip {
padding: 18px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #94a3b8;
font-size: 12px;
background: #f8fafc;
&:hover {
border-color: #f97316;
}
&.selected {
background: #fff7ed;
border-color: #f97316;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #f97316;
font-size: 18px;
font-weight: 800;
.empty-btn-row {
display: flex;
gap: 12px;
}
}
}

View File

@@ -3,23 +3,54 @@
<!-- 1. 楼层标题设置 (Floor Header) -->
<div class="panel-section">
<div class="section-title">楼层大标题设置</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">展示楼层标题</span>
<el-switch v-model="options.showFloorTitle" size="small" />
</div>
<div class="form-row-2">
<div class="form-item">
<span class="item-label">楼层编号</span>
<el-input v-model="options.floorNum" size="small" placeholder="如:1F" style="width: 90px;" />
<el-input v-model="options.floorNum" size="small" placeholder="如:1F" style="width: 90px;" clearable />
</div>
<div class="form-item flex-1">
<span class="item-label">楼层名称</span>
<el-input v-model="options.title" size="small" placeholder="如:潮流数码" />
<el-input v-model="options.title" size="small" placeholder="如:潮流数码" clearable />
</div>
</div>
<div class="form-item">
<span class="item-label">英文/副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:DIGITAL & TECH" />
<el-input v-model="options.subtitle" size="small" placeholder="如:DIGITAL & TECH" clearable />
</div>
<!-- 更多链接选择器 -->
<div class="form-item">
<span class="item-label">“查看更多”跳转链接</span>
<div class="link-picker-wrap">
<el-input
v-model="options.moreUrl"
size="small"
placeholder="如:/goodsList 或分类活动页面"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectMoreLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.moreUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.moreUrl">{{ options.moreUrl }}</span>
</div>
</div>
</div>
@@ -33,7 +64,7 @@
</div>
<div class="tags-editor-wrap">
<div v-for="(tag, tIdx) in options.subCategories" :key="tIdx" class="tag-edit-row">
<el-input v-model="options.subCategories[tIdx]" size="small" placeholder="分类名称" />
<el-input v-model="options.subCategories[tIdx]" size="small" placeholder="分类名称" clearable />
<el-button link type="danger" size="small" @click="options.subCategories.splice(tIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
@@ -41,29 +72,89 @@
</div>
</div>
<!-- 3. 左侧专属营销竖版海报 -->
<!-- 3. 左侧专属营销竖版海报 / 横幅 (横幅楼层 bannerFloor 与宫格楼层通用) -->
<div class="panel-section">
<div class="section-title">左侧专属大促海报</div>
<div class="form-item">
<span class="item-label">展示左侧海报</span>
<div class="section-title">左侧营销海报 / 横幅设置</div>
<div class="form-item flex-row">
<span class="item-label">展示左侧海报横幅</span>
<el-switch v-model="options.showPoster" size="small" />
</div>
<template v-if="options.showPoster !== false">
<div class="form-item">
<span class="item-label">海报图片 URL</span>
<el-input v-model="options.posterImg" size="small" placeholder="图片链接" clearable />
<span class="item-label">海报横幅图片</span>
<div class="picker-flex-wrap">
<div
class="thumb-preview-box"
@click="handleSelectPosterImg"
:title="currentPosterImg ? '点击更换海报' : '点击选择图片'"
>
<template v-if="currentPosterImg">
<img :src="currentPosterImg" alt="海报" class="preview-img" />
<span class="thumb-clear-btn" @click.stop="handleClearPosterImg" title="清除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="thumb-empty-icon"><Picture /></el-icon>
<span class="thumb-empty-tip">选图</span>
</template>
</div>
<div class="input-with-action flex-1">
<el-input
v-model="currentPosterImg"
size="small"
placeholder="请输入或选择海报横幅图地址"
clearable
/>
<el-button
size="small"
class="action-picker-btn"
@click="handleSelectPosterImg"
>
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
</div>
</div>
<div class="form-item">
<div class="form-item mt-10">
<span class="item-label">海报主标题</span>
<el-input v-model="options.posterTitle" size="small" placeholder="如:数码潮电 酷玩季" />
<el-input v-model="options.posterTitle" size="small" placeholder="如:数码潮电 酷玩季" clearable />
</div>
<div class="form-item">
<span class="item-label">海报副标题</span>
<el-input v-model="options.posterSub" size="small" placeholder="如:领券最高立减 600 元" />
<el-input v-model="options.posterSub" size="small" placeholder="如:领券最高立减 600 元" clearable />
</div>
<div class="form-item">
<span class="item-label">跳转链接</span>
<el-input v-model="options.posterUrl" size="small" placeholder="如:/goods" />
<span class="item-label">海报跳转链接</span>
<div class="link-picker-wrap">
<el-input
v-model="currentPosterUrl"
size="small"
placeholder="如:/goods 或活动专题"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectPosterLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="currentPosterUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="currentPosterUrl">{{ currentPosterUrl }}</span>
</div>
</div>
</template>
</div>
@@ -71,7 +162,7 @@
<!-- 4. 右侧商品列数与排版 -->
<div class="panel-section">
<div class="section-title">右侧商品排版</div>
<div class="form-item">
<div class="form-item flex-row">
<span class="item-label">单行展示列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="3">3 列</el-radio-button>
@@ -85,17 +176,27 @@
<div class="panel-section">
<div class="section-title flex-between">
<span>楼层商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showGoodsModal = true">
选择真实商品
</el-button>
<div style="display: flex; gap: 8px;">
<el-button v-if="options.goods?.length" link type="danger" size="small" @click="options.goods = []">清空</el-button>
<el-button type="primary" size="small" @click="handleOpenGoodsPicker">选择商品</el-button>
</div>
</div>
<div class="goods-list-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="goods-item-row">
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="thumb-wrap-mini" @click="handleSelectGoodsThumb(g)" title="点击更换商品图片">
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="thumb-tip">换图</div>
</div>
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
<span class="g-name" :title="g.goodsName">{{ g.goodsName }}</span>
<div class="g-meta-row">
<span class="g-price">¥{{ g.price }}</span>
<el-button link type="primary" size="small" @click="handleSelectGoodsLink(g)" :title="g.url || '设置商品链接'">
<el-icon><Link /></el-icon>
<span style="font-size: 11px; margin-left: 2px;">{{ g.url ? '已配链接' : '选链接' }}</span>
</el-button>
</div>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
@@ -104,34 +205,32 @@
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showGoodsModal" title="选择楼层商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 商品选择弹窗 (兜底) -->
<el-dialog
v-if="!openGoodsPicker && showGoodsModal"
v-model="showGoodsModal"
title="从商品库选择商品"
width="1160px"
append-to-body
destroy-on-close
>
<goods-dialog
:selectedWay="tempSelectedGoods"
@selected="(val) => { tempSelectedGoods = val; }"
/>
<template #footer>
<el-button @click="showGoodsModal = false">完成</el-button>
<el-button @click="showGoodsModal = false">取消</el-button>
<el-button type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Plus, Delete, Check } from '@element-plus/icons-vue';
import { ref, computed, inject } from 'vue';
import GoodsDialog from '@/components/lili-dialog/goods-dialog.vue';
import { Plus, Delete, Link, Picture, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
@@ -146,6 +245,12 @@ const props = defineProps({
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const showGoodsModal = ref(false);
const tempSelectedGoods = ref([]);
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
const openGoodsPicker = inject('openGoodsPicker', null);
// 初始化缺省 options
if (props.options.showFloorTitle === undefined) props.options.showFloorTitle = true;
@@ -155,9 +260,33 @@ if (!props.options.subtitle) props.options.subtitle = 'DIGITAL & TECH';
if (!props.options.subCategories) {
props.options.subCategories = ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
}
if (!props.options.posterImg) {
props.options.posterImg = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
// 兼容 bannerFloor 与 gridFloor 的字段名双向打通
const currentPosterImg = computed({
get() {
return props.options.bannerImg || props.options.posterImg || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
},
set(val) {
props.options.posterImg = val;
props.options.bannerImg = val;
}
});
const currentPosterUrl = computed({
get() {
return props.options.bannerUrl || props.options.posterUrl || '';
},
set(val) {
props.options.posterUrl = val;
props.options.bannerUrl = val;
}
});
function handleClearPosterImg() {
props.options.posterImg = '';
props.options.bannerImg = '';
}
if (!props.options.posterTitle) props.options.posterTitle = '数码潮电 酷玩季';
if (!props.options.posterSub) props.options.posterSub = '领券最高立减 600 元';
if (!props.options.columns) props.options.columns = 4;
@@ -174,35 +303,150 @@ if (!props.options.goods) {
];
}
const mockPool = [
{ id: '101', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' }
];
function handleAddTag() {
if (!Array.isArray(props.options.subCategories)) {
props.options.subCategories = [];
}
props.options.subCategories.push('新分类');
props.options.subCategories.push('新标签');
}
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
// 1. 选择更多链接
function handleSelectMoreLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.moreUrl = url;
});
} else {
props.options.goods.push({ ...item });
ElMessageBox.prompt('请输入“查看更多”跳转链接:', '设置更多链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.moreUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.moreUrl = value.trim();
})
.catch(() => {});
}
}
// 2. 选择海报横幅图片
function handleSelectPosterImg() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) {
currentPosterImg.value = url;
}
});
} else {
ElMessageBox.prompt('请输入海报图片 URL 地址:', '选择海报图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: currentPosterImg.value || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
currentPosterImg.value = value.trim();
})
.catch(() => {});
}
}
// 3. 选择海报跳转链接
function handleSelectPosterLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) {
currentPosterUrl.value = url;
}
});
} else {
ElMessageBox.prompt('请输入海报跳转链接:', '设置海报链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: currentPosterUrl.value || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
currentPosterUrl.value = value.trim();
})
.catch(() => {});
}
}
// 4. 更换单个商品图片
function handleSelectGoodsThumb(g) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) g.thumbnail = url;
});
} else {
ElMessageBox.prompt('请输入商品图片 URL 地址:', '更换商品图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: g.thumbnail || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
g.thumbnail = value.trim();
})
.catch(() => {});
}
}
// 5. 配置商品专属链接
function handleSelectGoodsLink(g) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
if (url) g.url = url;
if (!g.goodsName && title) g.goodsName = title;
});
} else {
ElMessageBox.prompt('请输入商品跳转链接(如 /goodsDetail?goodsId=xxx):', '设置商品链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: g.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
g.url = value.trim();
})
.catch(() => {});
}
}
function handleOpenGoodsPicker() {
if (typeof openGoodsPicker === 'function') {
openGoodsPicker((selectedGoods) => {
applySelectedGoods(selectedGoods);
}, props.options.goods || []);
} else {
tempSelectedGoods.value = Array.isArray(props.options.goods) ? [...props.options.goods] : [];
showGoodsModal.value = true;
}
}
function confirmGoodsSelected() {
applySelectedGoods(tempSelectedGoods.value);
showGoodsModal.value = false;
}
function applySelectedGoods(selectedGoods) {
if (Array.isArray(selectedGoods)) {
props.options.goods = selectedGoods.map(g => ({
id: g.id || g.skuId || g.goodsId,
goodsId: g.goodsId,
goodsName: g.goodsName || g.name,
price: g.price,
originalPrice: g.originalPrice || g.price,
thumbnail: g.thumbnail || g.pic || defaultThumb,
url: g.url || ''
}));
}
}
</script>
@@ -231,43 +475,184 @@ function toggleSelect(item) {
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-direction: column;
gap: 5px;
margin-bottom: 10px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
font-weight: 500;
}
}
.form-row-2 {
display: flex;
gap: 10px;
margin-bottom: 8px;
gap: 8px;
}
.flex-1 {
flex: 1;
.tags-editor-wrap {
display: flex;
flex-direction: column;
gap: 6px;
.tag-edit-row {
display: flex;
align-items: center;
gap: 6px;
}
}
}
.tags-editor-wrap {
display: flex;
flex-direction: column;
gap: 6px;
.mr-4 {
margin-right: 4px;
}
.tag-edit-row {
.mt-10 {
margin-top: 10px;
}
.flex-1 {
flex: 1;
}
.picker-flex-wrap {
display: flex;
align-items: center;
gap: 8px;
.thumb-preview-box {
position: relative;
width: 58px;
height: 40px;
border-radius: 4px;
border: 1px dashed #cbd5e1;
background: #ffffff;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s;
&:hover {
border-color: #409eff;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-empty-icon {
font-size: 15px;
color: #94a3b8;
}
.thumb-empty-tip {
font-size: 10px;
color: #94a3b8;
line-height: 1;
margin-top: 2px;
}
.thumb-clear-btn {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.65);
color: #ffffff;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
.input-with-action {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.goods-list-preview {
max-height: 240px;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -281,12 +666,41 @@ function toggleSelect(item) {
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
.g-thumb {
width: 36px;
height: 36px;
.thumb-wrap-mini {
position: relative;
width: 38px;
height: 38px;
border-radius: 4px;
object-fit: cover;
overflow: hidden;
flex-shrink: 0;
cursor: pointer;
border: 1px solid #cbd5e1;
.g-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-tip {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.7);
color: #ffffff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .thumb-tip {
opacity: 1;
}
}
.g-info {
@@ -294,6 +708,7 @@ function toggleSelect(item) {
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
.g-name {
font-size: 12px;
@@ -303,68 +718,19 @@ function toggleSelect(item) {
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
.g-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
&.selected {
background: #eff6ff;
border-color: #3b82f6;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #3b82f6;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

@@ -1,48 +1,219 @@
<template>
<div class="panel-hot-zone">
<!-- 1. 热区底图设置 -->
<div class="panel-section">
<div class="section-title">热区底图</div>
<!-- URL 输入与选择图片按钮 -->
<div class="form-item">
<span class="item-label">大图 URL 地址</span>
<el-input v-model="options.imageUrl" size="small" placeholder="请输入热区底图 URL" clearable />
<div class="input-with-action">
<el-input
v-model="options.imageUrl"
size="small"
placeholder="请输入或选择热区大图 URL"
clearable
/>
<el-button
size="small"
class="action-picker-btn"
@click="handleSelectImage"
>
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
</div>
<div class="hotzone-preview" v-if="options.imageUrl">
<!-- 底图预览框 -->
<div class="hotzone-preview-box" v-if="options.imageUrl">
<img :src="options.imageUrl" alt="hotzone" class="img-preview" />
<div class="preview-actions-bar">
<el-button size="small" type="primary" plain @click="handleSelectImage">
<el-icon class="mr-4"><Picture /></el-icon>
更换底图
</el-button>
<el-button size="small" type="danger" plain @click="options.imageUrl = ''">
<el-icon class="mr-4"><Close /></el-icon>
移除底图
</el-button>
</div>
</div>
</div>
<!-- 2. 热区列表与链接选择 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>热区列表 ({{ options.zones?.length || 0 }})</span>
<el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区
</el-button>
<div class="header-action-group">
<el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区
</el-button>
</div>
</div>
<div class="zones-list">
<div class="zones-list" v-if="Array.isArray(options.zones) && options.zones.length">
<div
v-for="(zone, idx) in options.zones"
:key="idx"
class="zone-card"
class="zone-card-item"
>
<!-- 热区卡片头部 -->
<div class="zone-header">
<span>热区 {{ idx + 1 }}</span>
<el-button link type="danger" size="small" @click="options.zones.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
<span class="zone-idx-title">
<span class="zone-num-badge">{{ idx + 1 }}</span>
<span>{{ zone.title || `热区 ${idx + 1}` }}</span>
</span>
<div class="zone-header-btns">
<el-button
link
size="small"
:disabled="idx === 0"
@click="moveZone(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="idx === options.zones.length - 1"
@click="moveZone(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
link
type="danger"
size="small"
@click="options.zones.splice(idx, 1)"
title="删除热区"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="zone-inputs">
<el-input v-model="zone.title" size="small" placeholder="热区标题" />
<el-input v-model="zone.url" size="small" placeholder="跳转路径,如 /goods/1" />
<!-- 热区标题与链接设置 -->
<div class="zone-body-inputs">
<!-- 热区标题 -->
<div class="field-item">
<span class="field-lbl">热区标题</span>
<el-input
v-model="zone.title"
size="small"
placeholder="热区名称(如:领券中心 / 潮电专场)"
clearable
/>
</div>
<!-- 跳转链接 + 链接选择器 -->
<div class="field-item">
<span class="field-lbl">跳转链接</span>
<div class="link-picker-row">
<el-input
v-model="zone.url"
size="small"
placeholder="跳转链接(如 /goodsList?category=1)"
clearable
>
<template #prefix>
<el-icon class="link-prefix-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink(zone)"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<!-- 当前链接高亮标识 -->
<div class="link-current-tag" v-if="zone.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="zone.url">{{ zone.url }}</span>
</div>
</div>
<!-- 热区位置百分比 (X, Y, W, H) -->
<div class="field-item">
<span class="field-lbl">热区位置 (坐标与尺寸 %)</span>
<div class="coord-grid">
<div class="coord-cell">
<span class="coord-lbl">X</span>
<el-input-number
v-model="zone.left"
:min="0"
:max="100"
:step="1"
:precision="1"
size="small"
controls-position="right"
class="coord-input"
/>
</div>
<div class="coord-cell">
<span class="coord-lbl">Y</span>
<el-input-number
v-model="zone.top"
:min="0"
:max="100"
:step="1"
:precision="1"
size="small"
controls-position="right"
class="coord-input"
/>
</div>
<div class="coord-cell">
<span class="coord-lbl">宽</span>
<el-input-number
v-model="zone.width"
:min="1"
:max="100"
:step="1"
:precision="1"
size="small"
controls-position="right"
class="coord-input"
/>
</div>
<div class="coord-cell">
<span class="coord-lbl">高</span>
<el-input-number
v-model="zone.height"
:min="1"
:max="100"
:step="1"
:precision="1"
size="small"
controls-position="right"
class="coord-input"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="empty-zones-box" v-else>
<span>暂无热区,请点击右上角“添加热区”</span>
</div>
</div>
</div>
</template>
<script setup>
import { Plus, Delete } from '@element-plus/icons-vue';
import { inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
@@ -55,15 +226,76 @@ const props = defineProps({
}
});
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
// 1. 选择大图
function handleSelectImage() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) props.options.imageUrl = url;
});
} else {
ElMessageBox.prompt('请输入热区大图 URL 地址:', '选择热区底图', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.imageUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.imageUrl = value.trim();
})
.catch(() => {});
}
}
// 2. 选择热区跳转链接
function handleSelectLink(zone) {
if (openLinkPicker) {
openLinkPicker(({ url, title, raw }) => {
if (url) zone.url = url;
if (!zone.title && title) {
zone.title = title;
}
});
} else {
ElMessageBox.prompt('请输入跳转链接地址(如 /goodsList?category=1 或外部网址):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: zone.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接地址不能为空'
})
.then(({ value }) => {
zone.url = value.trim();
})
.catch(() => {});
}
}
// 3. 上移/下移热区
function moveZone(index, direction) {
const newIdx = index + direction;
if (!Array.isArray(props.options.zones)) return;
if (newIdx < 0 || newIdx >= props.options.zones.length) return;
const temp = props.options.zones[index];
props.options.zones[index] = props.options.zones[newIdx];
props.options.zones[newIdx] = temp;
}
// 4. 添加热区
function handleAddZone() {
if (!Array.isArray(props.options.zones)) props.options.zones = [];
const count = props.options.zones.length;
props.options.zones.push({
title: `热区 ${props.options.zones.length + 1}`,
url: '/',
top: 10,
left: 10,
width: 20,
height: 20
title: `热区 ${count + 1}`,
url: '/goodsList',
top: Math.min(10 + count * 5, 60),
left: Math.min(10 + (count % 3) * 30, 70),
width: 25,
height: 35
});
}
</script>
@@ -99,45 +331,219 @@ function handleAddZone() {
}
}
.hotzone-preview {
margin-top: 10px;
max-height: 140px;
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: 6px;
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
.img-preview {
width: 100%;
object-fit: cover;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
.zones-list {
display: flex;
flex-direction: column;
gap: 8px;
.hotzone-preview-box {
margin-top: 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
overflow: hidden;
background: #f8fafc;
.zone-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
.img-preview {
width: 100%;
max-height: 140px;
object-fit: cover;
display: block;
}
.zone-header {
.preview-actions-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f1f5f9;
border-top: 1px solid #e2e8f0;
}
}
}
.zones-list {
display: flex;
flex-direction: column;
gap: 12px;
.zone-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #edf2f7;
.zone-idx-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
font-weight: 600;
color: #334155;
.zone-num-badge {
background: #409EFF;
color: #ffffff;
font-size: 10px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.zone-inputs {
.zone-header-btns {
display: flex;
align-items: center;
gap: 2px;
}
}
.zone-body-inputs {
display: flex;
flex-direction: column;
gap: 10px;
.field-item {
display: flex;
flex-direction: column;
gap: 4px;
.field-lbl {
font-size: 11px;
font-weight: 500;
color: #64748b;
}
}
.link-picker-row {
display: flex;
align-items: center;
gap: 6px;
.link-prefix-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409EFF;
&:hover {
background: #409EFF;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 2px 8px;
margin-top: 2px;
overflow: hidden;
.tag-label {
color: #3b82f6;
flex-shrink: 0;
font-weight: 500;
}
.tag-val {
color: #1e40af;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
}
.coord-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
.coord-cell {
display: flex;
align-items: center;
gap: 4px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 2px 6px;
.coord-lbl {
font-size: 11px;
font-weight: 600;
color: #64748b;
flex-shrink: 0;
}
.coord-input {
width: 100%;
:deep(.el-input__wrapper) {
padding: 0 2px;
box-shadow: none !important;
}
:deep(.el-input__inner) {
font-size: 11px;
text-align: center;
}
}
}
}
}
}
}
.empty-zones-box {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #f8fafc;
}
.mr-4 {
margin-right: 4px;
}
}
</style>

View File

@@ -1,29 +1,82 @@
<template>
<div class="panel-notice-bar">
<!-- 1. 基础配置 -->
<!-- 1. 基础配置与图标设置 -->
<div class="panel-section">
<div class="section-title">快讯标语与更多</div>
<div class="section-title">快讯标语与图标</div>
<div class="form-item">
<span class="item-label">前缀标语</span>
<el-input v-model="options.title" size="small" placeholder="如:公告快讯 / 今日热点" />
<el-input v-model="options.title" size="small" placeholder="如:公告快讯 / 今日热点" clearable />
</div>
<div class="form-item">
<span class="item-label">展示更多入口</span>
<!-- 徽标图标 / 图片选择器 -->
<div class="form-item mt-10">
<span class="item-label">前缀徽标图标 (可选自定义图片)</span>
<div class="badge-icon-picker-row">
<div class="icon-thumb-preview" @click="handleSelectBadgeImg" title="点击选择/更换徽标图片">
<img v-if="options.badgeImg || options.icon" :src="options.badgeImg || options.icon" class="badge-img" alt="" />
<div v-else class="icon-placeholder">
<el-icon><Picture /></el-icon>
<span>选图</span>
</div>
<div class="hover-overlay" v-if="options.badgeImg || options.icon">换图</div>
</div>
<div class="icon-input-wrap">
<div class="input-btn-row">
<el-input
v-model="badgeImgVal"
size="small"
placeholder="留空展示默认喇叭图标"
clearable
/>
<el-button size="small" class="action-picker-btn" @click="handleSelectBadgeImg">
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
<div class="tip-sub-text">建议尺寸 24x24 或 32x32px 正方形小图标</div>
</div>
</div>
</div>
<div class="form-item flex-row mt-10">
<span class="item-label">展示“更多”入口</span>
<el-switch v-model="options.showMore" size="small" />
</div>
<template v-if="options.showMore !== false">
<div class="form-item">
<span class="item-label">更多文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:更多" />
<div class="form-item mt-10">
<span class="item-label">“更多”文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:更多" clearable />
</div>
<div class="form-item">
<span class="item-label">跳转链接</span>
<el-input v-model="options.moreUrl" size="small" placeholder="如:/article" />
<div class="form-item mt-10">
<span class="item-label">“更多”跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.moreUrl" size="small" placeholder="如:/article 或文章列表" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectMoreLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.moreUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.moreUrl">{{ options.moreUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.moreUrl = ''"><Close /></el-icon>
</div>
</div>
</template>
</div>
<!-- 2. 快讯条目列表 (匹配万米 Mg 结构化列表) -->
<!-- 2. 快讯条目列表 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>快讯条目 ({{ itemsList?.length || 0 }} 条)</span>
@@ -39,7 +92,7 @@
class="notice-card-item"
>
<div class="item-top-row">
<el-select v-model="item.tag" size="small" style="width: 80px">
<el-select v-model="item.tag" size="small" style="width: 86px" filterable allow-create placeholder="标签">
<el-option label="最新" value="最新" />
<el-option label="活动" value="活动" />
<el-option label="通知" value="通知" />
@@ -71,12 +124,33 @@
</div>
</div>
<div class="item-title-row">
<el-input v-model="item.title" size="small" placeholder="快讯内容标题" />
<div class="item-title-row mt-6">
<el-input v-model="item.title" size="small" placeholder="快讯内容标题" clearable />
</div>
<div class="item-link-row">
<el-input v-model="item.url" size="small" placeholder="跳转路径(如: /article/101)" />
<div class="item-link-row mt-6">
<div class="link-picker-wrap">
<el-input v-model="item.url" size="small" placeholder="跳转路径(如: /article/101 或文章链接)" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectItemLink(item)"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="item.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="item.url">{{ item.url }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="item.url = ''"><Close /></el-icon>
</div>
</div>
</div>
</div>
@@ -85,8 +159,9 @@
</template>
<script setup>
import { computed } from 'vue';
import { Plus, Delete, Top, Bottom } from '@element-plus/icons-vue';
import { computed, inject } from 'vue';
import { Plus, Delete, Top, Bottom, Link, Picture, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
@@ -99,11 +174,25 @@ const props = defineProps({
}
});
// 全局选择器注入
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
if (!props.options.title) props.options.title = '公告快讯';
if (props.options.showMore === undefined) props.options.showMore = true;
if (!props.options.moreText) props.options.moreText = '更多';
if (!props.options.moreUrl) props.options.moreUrl = '/article';
const badgeImgVal = computed({
get() {
return props.options.badgeImg || props.options.icon || '';
},
set(v) {
props.options.badgeImg = v;
props.options.icon = v;
}
});
// 统一 items 数据源
if (!props.options.items && !props.options.notices) {
props.options.items = [
@@ -125,6 +214,74 @@ const itemsList = computed(() => {
return props.options.items;
});
// 1. 选择前缀徽标图片
function handleSelectBadgeImg() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) {
props.options.badgeImg = url;
props.options.icon = url;
}
});
} else {
ElMessageBox.prompt('请输入快讯徽标图片 URL:', '选择快讯图标', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.badgeImg || props.options.icon || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.badgeImg = value.trim();
props.options.icon = value.trim();
})
.catch(() => {});
}
}
// 2. 选择“更多”链接
function handleSelectMoreLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.moreUrl = url;
});
} else {
ElMessageBox.prompt('请输入“更多”跳转链接:', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.moreUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.moreUrl = value.trim();
})
.catch(() => {});
}
}
// 3. 选择单条快讯链接
function handleSelectItemLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
if (url) item.url = url;
if (!item.title && title) item.title = title;
});
} else {
ElMessageBox.prompt('请输入快讯跳转链接:', '设置快讯链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
item.url = value.trim();
})
.catch(() => {});
}
}
function handleAddItem() {
props.options.items.push({
tag: '最新',
@@ -148,8 +305,8 @@ function moveItem(index, step) {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
@@ -167,15 +324,168 @@ function moveItem(index, step) {
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-direction: column;
gap: 5px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
font-weight: 500;
}
}
}
.mt-6 {
margin-top: 6px;
}
.mt-10 {
margin-top: 10px;
}
.mr-4 {
margin-right: 4px;
}
.badge-icon-picker-row {
display: flex;
align-items: center;
gap: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
.icon-thumb-preview {
position: relative;
width: 40px;
height: 40px;
border-radius: 6px;
border: 1px solid #cbd5e1;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
.badge-img {
width: 100%;
height: 100%;
object-fit: contain;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 10px;
color: #64748b;
gap: 2px;
}
.hover-overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.65);
color: #ffffff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .hover-overlay {
opacity: 1;
}
}
.icon-input-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.input-btn-row {
display: flex;
gap: 6px;
}
.tip-sub-text {
font-size: 11px;
color: #94a3b8;
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.url-clear-icon {
font-size: 12px;
color: #94a3b8;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: #ef4444;
}
}
}
@@ -183,32 +493,28 @@ function moveItem(index, step) {
.notice-items-container {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
max-height: 420px;
overflow-y: auto;
.notice-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
.item-top-row {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
.item-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
}
.item-title-row, .item-link-row {
width: 100%;
}
}
}
}

View File

@@ -1,5 +1,6 @@
<template>
<div class="panel-promo-group">
<!-- 1. 排版列数 -->
<div class="panel-section">
<div class="section-title">排版列数</div>
<div class="form-item">
@@ -12,40 +13,156 @@
</div>
</div>
<!-- 2. 活动卡片列表 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>活动卡片列表</span>
<span>活动卡片列表 ({{ options.cards?.length || 0 }})</span>
<el-button link type="primary" size="small" @click="handleAddCard">
<el-icon><Plus /></el-icon> 添加卡片
</el-button>
</div>
<div class="cards-list">
<div class="cards-list" v-if="Array.isArray(options.cards) && options.cards.length">
<div
v-for="(card, idx) in options.cards"
:key="idx"
class="promo-card-item"
>
<!-- 顶部序号与工具按钮 -->
<div class="card-header">
<span>卡片 {{ idx + 1 }}</span>
<el-button link type="danger" size="small" @click="options.cards.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
<span class="card-idx-badge">卡片 {{ idx + 1 }}{{ card.title ? ` · ${card.title}` : '' }}</span>
<div class="card-actions">
<el-button
link
size="small"
:disabled="idx === 0"
@click="moveCard(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="idx === options.cards.length - 1"
@click="moveCard(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
link
type="danger"
size="small"
@click="options.cards.splice(idx, 1)"
title="删除卡片"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<!-- 卡片属性输入 -->
<div class="card-inputs">
<el-input v-model="card.title" size="small" placeholder="主标题(如:今日秒杀)" />
<el-input v-model="card.subtitle" size="small" placeholder="副标题(如:抢神券立减50)" />
<el-input v-model="card.bgImg" size="small" placeholder="卡片底图 URL" />
<el-input v-model="card.url" size="small" placeholder="活动落地页链接" />
<!-- 标题行 -->
<div class="input-row">
<span class="field-label">主标题</span>
<el-input v-model="card.title" size="small" placeholder="如:限时秒杀" clearable />
</div>
<!-- 副标题行 -->
<div class="input-row">
<span class="field-label">副标题</span>
<el-input v-model="card.subtitle" size="small" placeholder="如:抢券立减50元 · 爆款直降" clearable />
</div>
<!-- 卡片底图 (图片选择器) -->
<div class="input-row">
<span class="field-label">卡片底图</span>
<div class="picker-flex-wrap">
<!-- 缩略图预览与快捷替换 -->
<div
class="thumb-preview-box"
@click="handleSelectImage(card)"
:title="card.bgImg ? '点击更换底图' : '点击上传底图'"
>
<template v-if="card.bgImg">
<img :src="card.bgImg" alt="底图" class="preview-img" />
<span class="thumb-clear-btn" @click.stop="card.bgImg = ''" title="清除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="thumb-empty-icon"><Picture /></el-icon>
<span class="thumb-empty-tip">选图</span>
</template>
</div>
<!-- URL 输入框与选择图片按钮 -->
<div class="input-with-action">
<el-input
v-model="card.bgImg"
size="small"
placeholder="底图 URL,可直接粘贴或选择"
clearable
/>
<el-button
size="small"
class="action-picker-btn"
@click="handleSelectImage(card)"
>
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
</div>
</div>
<!-- 跳转链接 (链接选择器) -->
<div class="input-row">
<span class="field-label">跳转链接</span>
<div class="link-picker-wrap">
<el-input
v-model="card.url"
size="small"
placeholder="跳转链接(如 /goodsList?seckill=1)"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink(card)"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="card.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="card.url">{{ card.url }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="empty-cards-tip" v-else>
<span>暂无活动卡片,请点击上方“添加卡片”</span>
</div>
</div>
</div>
</template>
<script setup>
import { Plus, Delete } from '@element-plus/icons-vue';
import { inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
@@ -58,13 +175,73 @@ const props = defineProps({
}
});
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
// 1. 选择卡片背景图片
function handleSelectImage(card) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) card.bgImg = url;
});
} else {
ElMessageBox.prompt('请输入卡片背景图 URL 地址:', '选择卡片底图', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: card.bgImg || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
card.bgImg = value.trim();
})
.catch(() => {});
}
}
// 2. 选择跳转链接
function handleSelectLink(card) {
if (openLinkPicker) {
openLinkPicker(({ url, title, raw }) => {
if (url) card.url = url;
if (!card.title && title) {
card.title = title;
}
});
} else {
ElMessageBox.prompt('请输入跳转链接(如 /goodsList?seckill=1 或完整网址):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: card.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
card.url = value.trim();
})
.catch(() => {});
}
}
// 3. 上移/下移卡片
function moveCard(index, direction) {
const newIdx = index + direction;
if (!Array.isArray(props.options.cards)) return;
if (newIdx < 0 || newIdx >= props.options.cards.length) return;
const temp = props.options.cards[index];
props.options.cards[index] = props.options.cards[newIdx];
props.options.cards[newIdx] = temp;
}
// 4. 添加卡片
function handleAddCard() {
if (!Array.isArray(props.options.cards)) props.options.cards = [];
props.options.cards.push({
title: '大促专场',
subtitle: '限量特惠抢购',
bgImg: '',
url: '/goods'
url: '/goodsList'
});
}
</script>
@@ -99,33 +276,209 @@ function handleAddCard() {
color: #475569;
}
}
}
.cards-list {
display: flex;
flex-direction: column;
gap: 10px;
.cards-list {
display: flex;
flex-direction: column;
gap: 14px;
.promo-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
.promo-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
transition: border-color 0.2s, box-shadow 0.2s;
.card-header {
display: flex;
justify-content: space-between;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #edf2f7;
.card-idx-badge {
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
font-weight: 600;
color: #334155;
}
.card-inputs {
.card-actions {
display: flex;
align-items: center;
gap: 4px;
}
}
.card-inputs {
display: flex;
flex-direction: column;
gap: 10px;
.input-row {
display: flex;
flex-direction: column;
gap: 4px;
.field-label {
font-size: 11px;
font-weight: 500;
color: #64748b;
}
}
.picker-flex-wrap {
display: flex;
align-items: center;
gap: 8px;
.thumb-preview-box {
position: relative;
width: 58px;
height: 40px;
border-radius: 4px;
border: 1px dashed #cbd5e1;
background: #ffffff;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s;
&:hover {
border-color: #409EFF;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-empty-icon {
font-size: 15px;
color: #94a3b8;
}
.thumb-empty-tip {
font-size: 10px;
color: #94a3b8;
line-height: 1;
margin-top: 2px;
}
.thumb-clear-btn {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.65);
color: #ffffff;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
.input-with-action {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409EFF;
&:hover {
background: #409EFF;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 2px 8px;
margin-top: 2px;
overflow: hidden;
.tag-label {
color: #3b82f6;
flex-shrink: 0;
font-weight: 500;
}
.tag-val {
color: #1e40af;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
}
}
}
}
.empty-cards-tip {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #f8fafc;
}
.mr-4 {
margin-right: 4px;
}
}
</style>

View File

@@ -46,9 +46,14 @@
<div class="panel-section">
<div class="section-title flex-between">
<span>推荐商品池 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showModal = true">
选择真实商品
</el-button>
<div class="btn-group">
<el-button v-if="options.goods?.length" link type="danger" size="small" @click="options.goods = []">
清空
</el-button>
<el-button type="primary" size="small" @click="handleOpenGoodsPicker">
选择商品
</el-button>
</div>
</div>
<div class="goods-list-preview" v-if="options.goods?.length">
@@ -56,7 +61,7 @@
<span class="idx-tag">{{ idx === 0 ? '精选' : idx + 1 }}</span>
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-name" :title="g.goodsName">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
@@ -64,36 +69,39 @@
</el-button>
</div>
</div>
<div class="goods-empty-tip" v-else>
<span>暂未选择特定商品,前台将展示系统推荐商品</span>
<el-button type="primary" link size="small" @click="handleOpenGoodsPicker">
+ 点击添加商品
</el-button>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showModal" title="选择推荐商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 兜底商品选择弹窗(当未注入全局 openGoodsPicker 时生效) -->
<el-dialog
v-if="!openGoodsPicker && showModal"
v-model="showModal"
title="从商品库选择商品"
width="1160px"
append-to-body
destroy-on-close
>
<goods-dialog
:selectedWay="tempSelectedGoods"
@selected="(val) => { tempSelectedGoods = val; }"
/>
<template #footer>
<el-button @click="showModal = false">完成</el-button>
<el-button @click="showModal = false">取消</el-button>
<el-button type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Delete, Check } from '@element-plus/icons-vue';
import { ref, inject } from 'vue';
import GoodsDialog from '@/components/lili-dialog/goods-dialog.vue';
import { Delete } from '@element-plus/icons-vue';
const props = defineProps({
component: {
@@ -108,6 +116,8 @@ const props = defineProps({
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const showModal = ref(false);
const openGoodsPicker = inject('openGoodsPicker', null);
const tempSelectedGoods = ref([]);
if (!props.options.title) props.options.title = '猜你喜欢';
if (!props.options.subtitle) props.options.subtitle = '真实推荐商品流';
@@ -128,26 +138,32 @@ if (!props.options.goods) {
];
}
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' }
];
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
function handleOpenGoodsPicker() {
if (typeof openGoodsPicker === 'function') {
openGoodsPicker((selectedGoods) => {
applySelectedGoods(selectedGoods);
}, props.options.goods || []);
} else {
tempSelectedGoods.value = Array.isArray(props.options.goods) ? [...props.options.goods] : [];
showModal.value = true;
}
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
} else {
props.options.goods.push({ ...item });
function confirmGoodsSelected() {
applySelectedGoods(tempSelectedGoods.value);
showModal.value = false;
}
function applySelectedGoods(selectedGoods) {
if (Array.isArray(selectedGoods)) {
props.options.goods = selectedGoods.map(g => ({
id: g.id || g.skuId || g.goodsId,
goodsId: g.goodsId,
goodsName: g.goodsName || g.name,
price: g.price,
originalPrice: g.originalPrice || g.price,
thumbnail: g.thumbnail || g.pic || defaultThumb
}));
}
}
</script>
@@ -172,6 +188,12 @@ function toggleSelect(item) {
justify-content: space-between;
align-items: center;
}
.btn-group {
display: flex;
align-items: center;
gap: 8px;
}
}
.form-item {
@@ -190,7 +212,7 @@ function toggleSelect(item) {
}
.goods-list-preview {
max-height: 200px;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -204,14 +226,16 @@ function toggleSelect(item) {
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #f1f5f9;
.idx-tag {
font-size: 10px;
font-size: 11px;
font-weight: 700;
background: #fee2e2;
color: #dc2626;
padding: 2px 4px;
color: #64748b;
background: #e2e8f0;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.g-thumb {
@@ -243,60 +267,18 @@ function toggleSelect(item) {
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
.goods-empty-tip {
padding: 16px;
text-align: center;
background: #f8fafc;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #ef4444;
}
&.selected {
background: #fef2f2;
border-color: #ef4444;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #ef4444;
font-size: 18px;
font-weight: 800;
}
font-size: 12px;
color: #94a3b8;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
}
</style>

View File

@@ -5,15 +5,77 @@
<div class="form-item">
<span class="item-label">主标题文案</span>
<el-input v-model="options.mainTitle" size="small" placeholder="如:限时特卖" />
<el-input v-model="options.mainTitle" size="small" placeholder="如:限时特卖" clearable />
</div>
<div class="form-item">
<!-- 主标题前缀图标 / 图片 -->
<div class="form-item mt-10">
<span class="item-label">标题图标 / 徽标图片 (可选)</span>
<div class="title-icon-picker-row">
<div class="icon-thumb-preview" @click="handleSelectTitleImg" title="点击选择/更换标题图标">
<img v-if="titleImgVal" :src="titleImgVal" class="title-img" alt="" />
<div v-else class="icon-placeholder">
<el-icon><Picture /></el-icon>
<span>选图</span>
</div>
<div class="hover-overlay" v-if="titleImgVal">换图</div>
</div>
<div class="icon-input-wrap">
<div class="input-btn-row">
<el-input
v-model="titleImgVal"
size="small"
placeholder="请输入或选择标题前缀图标"
clearable
/>
<el-button size="small" class="action-picker-btn" @click="handleSelectTitleImg">
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
<div class="tip-sub-text">建议尺寸 32x32 或 48x48px,展示在标题前缀</div>
</div>
</div>
</div>
<!-- 主标题跳转链接 -->
<div class="form-item mt-10">
<span class="item-label">主标题点击跳转链接 (可选)</span>
<div class="link-picker-wrap">
<el-input
v-model="options.titleUrl"
size="small"
placeholder="如 /goods 或活动专区,留空不可点击"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectTitleLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.titleUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.titleUrl">{{ options.titleUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.titleUrl = ''"><Close /></el-icon>
</div>
</div>
<div class="form-item mt-10">
<span class="item-label">副标题文字</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:精选爆款 每日早十点上新" />
<el-input v-model="options.subtitle" size="small" placeholder="如:精选爆款 每日早十点上新" clearable />
</div>
<div class="form-item flex-row">
<div class="form-item flex-row mt-10">
<span class="item-label">对齐方式</span>
<el-radio-group v-model="options.align" size="small">
<el-radio-button label="left">靠左对齐</el-radio-button>
@@ -21,25 +83,57 @@
</el-radio-group>
</div>
<div class="form-item flex-row">
<div class="form-item flex-row mt-10">
<span class="item-label">显示右侧“查看更多”</span>
<el-switch v-model="options.showMore" size="small" />
</div>
<div class="form-item" v-if="options.showMore">
<span class="item-label">更多链接文案</span>
<el-input v-model="options.moreText" size="small" placeholder="查看更多 >" />
</div>
<template v-if="options.showMore">
<div class="form-item mt-10">
<span class="item-label">更多链接文案</span>
<el-input v-model="options.moreText" size="small" placeholder="查看更多 >" clearable />
</div>
<div class="form-item" v-if="options.showMore">
<span class="item-label">更多跳转地址</span>
<el-input v-model="options.moreUrl" size="small" placeholder="如 /goods" />
</div>
<div class="form-item mt-10">
<span class="item-label">更多跳转地址</span>
<div class="link-picker-wrap">
<el-input
v-model="options.moreUrl"
size="small"
placeholder="如 /goods 或活动专题"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectMoreLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.moreUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.moreUrl">{{ options.moreUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.moreUrl = ''"><Close /></el-icon>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
import { Link, Picture, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
@@ -50,10 +144,93 @@ const props = defineProps({
required: true
}
});
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
const titleImgVal = computed({
get() {
return props.options.titleImg || props.options.icon || '';
},
set(v) {
props.options.titleImg = v;
props.options.icon = v;
}
});
// 1. 选择前缀图片
function handleSelectTitleImg() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) {
props.options.titleImg = url;
props.options.icon = url;
}
});
} else {
ElMessageBox.prompt('请输入标题图标图片 URL:', '选择标题图标', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.titleImg || props.options.icon || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.titleImg = value.trim();
props.options.icon = value.trim();
})
.catch(() => {});
}
}
// 2. 选择主标题跳转链接
function handleSelectTitleLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.titleUrl = url;
});
} else {
ElMessageBox.prompt('请输入主标题跳转链接:', '设置标题链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.titleUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.titleUrl = value.trim();
})
.catch(() => {});
}
}
// 3. 选择“查看更多”链接
function handleSelectMoreLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.moreUrl = url;
});
} else {
ElMessageBox.prompt('请输入“查看更多”跳转链接:', '设置更多链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.moreUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.moreUrl = value.trim();
})
.catch(() => {});
}
}
</script>
<style lang="scss" scoped>
.panel-title-bar {
padding: 8px 4px;
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
@@ -69,7 +246,6 @@ const props = defineProps({
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.flex-row {
flex-direction: row;
@@ -80,6 +256,153 @@ const props = defineProps({
.item-label {
font-size: 12px;
color: #475569;
font-weight: 500;
}
}
}
.mt-10 {
margin-top: 10px;
}
.mr-4 {
margin-right: 4px;
}
.title-icon-picker-row {
display: flex;
align-items: center;
gap: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
.icon-thumb-preview {
position: relative;
width: 44px;
height: 44px;
border-radius: 6px;
border: 1px solid #cbd5e1;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
.title-img {
width: 100%;
height: 100%;
object-fit: contain;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 10px;
color: #64748b;
gap: 2px;
}
.hover-overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.65);
color: #ffffff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .hover-overlay {
opacity: 1;
}
}
.icon-input-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.input-btn-row {
display: flex;
gap: 6px;
}
.tip-sub-text {
font-size: 11px;
color: #94a3b8;
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.url-clear-icon {
font-size: 12px;
color: #94a3b8;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: #ef4444;
}
}
}

View File

@@ -1,8 +1,8 @@
<template>
<div class="panel-user-benefits">
<!-- 用户问候与标语 -->
<!-- 1. 用户问候与头像设置 -->
<div class="panel-section">
<div class="section-title">问候信息</div>
<div class="section-title">问候与头像设置</div>
<div class="form-item">
<span class="item-label">状态标签</span>
<el-input v-model="options.badgeText" size="small" placeholder="如:请登录" clearable />
@@ -15,34 +15,103 @@
<span class="item-label">副标语提示</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:登录后查看专属权益" clearable />
</div>
<!-- 头像图片选择器 -->
<div class="form-item mt-10">
<span class="item-label">自定义头像 URL (留空展示默认头像)</span>
<el-input v-model="options.avatar" size="small" placeholder="请输入头像图片地址" clearable />
<span class="item-label">用户头像</span>
<div class="avatar-upload-box">
<div class="avatar-preview-wrap" @click="handleSelectAvatar" title="点击选择/更换头像">
<img v-if="options.avatar" :src="options.avatar" alt="avatar" class="avatar-img" />
<div v-else class="avatar-empty">
<el-icon><Picture /></el-icon>
<span>选头像</span>
</div>
<div class="avatar-hover-tip" v-if="options.avatar">换图</div>
</div>
<div class="avatar-right-controls">
<div class="avatar-input-row">
<el-input
v-model="options.avatar"
size="small"
placeholder="留空展示系统默认头像"
clearable
/>
<el-button size="small" class="action-picker-btn" @click="handleSelectAvatar">
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
<div class="sub-tip-text">推荐比例 1:1 圆形头像,建议尺寸 100x100px</div>
</div>
</div>
</div>
</div>
<!-- 登录与注册按钮 -->
<!-- 2. 登录与注册按钮 -->
<div class="panel-section">
<div class="section-title">按钮配置</div>
<div class="section-title">登录 / 注册按钮配置</div>
<div class="form-item">
<span class="item-label">登录按钮文案</span>
<el-input v-model="options.loginText" size="small" placeholder="登录" />
</div>
<div class="form-item mt-10">
<span class="item-label">登录跳转路径</span>
<el-input v-model="options.loginUrl" size="small" placeholder="/login" />
<span class="item-label">登录跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.loginUrl" size="small" placeholder="/login" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink('loginUrl', '登录跳转链接')"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.loginUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.loginUrl">{{ options.loginUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.loginUrl = ''"><Close /></el-icon>
</div>
</div>
<div class="form-item mt-10">
<span class="item-label">注册按钮文案</span>
<el-input v-model="options.registerText" size="small" placeholder="注册" />
</div>
<div class="form-item mt-10">
<span class="item-label">注册跳转路径</span>
<el-input v-model="options.registerUrl" size="small" placeholder="/signUp" />
<span class="item-label">注册跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.registerUrl" size="small" placeholder="/signUp" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink('registerUrl', '注册跳转链接')"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.registerUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.registerUrl">{{ options.registerUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.registerUrl = ''"><Close /></el-icon>
</div>
</div>
</div>
<!-- 3项指标节点 (购物车 / 优惠券 / 订单) -->
<!-- 3. 指标节点展示 (购物车 / 优惠券 / 订单) -->
<div class="panel-section">
<div class="section-title">指标节点展示</div>
@@ -58,8 +127,29 @@
<el-input v-model="options.cartText" size="small" placeholder="购物车" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.cartUrl" size="small" placeholder="/cart" />
<span class="sub-label">跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.cartUrl" size="small" placeholder="/cart" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink('cartUrl', '购物车跳转链接')"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.cartUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.cartUrl">{{ options.cartUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.cartUrl = ''"><Close /></el-icon>
</div>
</div>
</div>
</div>
@@ -76,8 +166,29 @@
<el-input v-model="options.couponText" size="small" placeholder="优惠券" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.couponUrl" size="small" placeholder="/member/coupon" />
<span class="sub-label">跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.couponUrl" size="small" placeholder="/member/coupon" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink('couponUrl', '优惠券跳转链接')"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.couponUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.couponUrl">{{ options.couponUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.couponUrl = ''"><Close /></el-icon>
</div>
</div>
</div>
</div>
@@ -94,8 +205,72 @@
<el-input v-model="options.orderText" size="small" placeholder="订单" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.orderUrl" size="small" placeholder="/member/order" />
<span class="sub-label">跳转链接</span>
<div class="link-picker-wrap">
<el-input v-model="options.orderUrl" size="small" placeholder="/member/order" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink('orderUrl', '订单跳转链接')"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.orderUrl">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.orderUrl">{{ options.orderUrl }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="options.orderUrl = ''"><Close /></el-icon>
</div>
</div>
</div>
</div>
</div>
<!-- 4. 底部快捷便民服务入口 -->
<div class="panel-section">
<div class="section-title">底部便民服务入口</div>
<div class="services-list">
<div v-for="(srv, sIdx) in ensureServices" :key="sIdx" class="service-config-item">
<div class="service-header-row">
<span class="service-idx">{{ sIdx + 1 }}.</span>
<el-input v-model="srv.name" size="small" placeholder="名称" style="width: 80px;" />
<el-select v-model="srv.icon" size="small" style="width: 90px;">
<el-option label="收藏" value="Star" />
<el-option label="足迹" value="Clock" />
<el-option label="红包" value="Present" />
<el-option label="客服" value="Service" />
</el-select>
</div>
<div class="service-link-row mt-6">
<div class="link-picker-wrap flex-1">
<el-input v-model="srv.url" size="small" placeholder="跳转路径" clearable>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectServiceLink(srv)"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选链接</span>
</el-button>
</div>
</div>
<div class="link-current-tag" v-if="srv.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="srv.url">{{ srv.url }}</span>
<el-icon class="url-clear-icon" title="清除链接" @click.stop="srv.url = ''"><Close /></el-icon>
</div>
</div>
</div>
@@ -104,6 +279,10 @@
</template>
<script setup>
import { computed, inject } from 'vue';
import { Picture, Link, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
@@ -115,6 +294,10 @@ const props = defineProps({
}
});
// 全局选择器注入
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
// 初始化缺省配置
if (props.options.badgeText === undefined) props.options.badgeText = '请登录';
if (props.options.welcomeText === undefined) props.options.welcomeText = '欢迎来到商城';
@@ -135,10 +318,89 @@ if (props.options.orderText === undefined) props.options.orderText = '订单';
if (props.options.cartUrl === undefined) props.options.cartUrl = '/cart';
if (props.options.couponUrl === undefined) props.options.couponUrl = '/member/coupon';
if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/order';
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
];
const ensureServices = computed(() => {
if (!Array.isArray(props.options.services) || props.options.services.length === 0) {
props.options.services = JSON.parse(JSON.stringify(defaultServices));
}
return props.options.services;
});
// 1. 选择头像
function handleSelectAvatar() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) props.options.avatar = url;
});
} else {
ElMessageBox.prompt('请输入用户头像图片 URL 地址:', '选择头像', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.avatar || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.avatar = value.trim();
})
.catch(() => {});
}
}
// 2. 通用选择链接
function handleSelectLink(key, title) {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options[key] = url;
});
} else {
ElMessageBox.prompt(`请输入${title}(如 /member 或外部网址):`, `设置${title}`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options[key] || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options[key] = value.trim();
})
.catch(() => {});
}
}
// 3. 服务项链接选择
function handleSelectServiceLink(srv) {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) srv.url = url;
});
} else {
ElMessageBox.prompt(`请输入【${srv.name}】跳转链接:`, '设置便民服务链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: srv.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
srv.url = value.trim();
})
.catch(() => {});
}
}
</script>
<style lang="scss" scoped>
.panel-user-benefits {
padding: 8px 4px;
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
@@ -148,7 +410,7 @@ if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/orde
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
margin-bottom: 10px;
}
.form-item {
@@ -159,49 +421,225 @@ if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/orde
.item-label {
font-size: 12px;
color: #475569;
font-weight: 500;
}
}
}
.mt-6 {
margin-top: 6px;
}
.mt-10 {
margin-top: 10px;
}
.mr-4 {
margin-right: 4px;
}
.flex-1 {
flex: 1;
}
.avatar-upload-box {
display: flex;
align-items: center;
gap: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
.avatar-preview-wrap {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #cbd5e1;
cursor: pointer;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-empty {
width: 100%;
height: 100%;
background: #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 10px;
gap: 2px;
}
.avatar-hover-tip {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.65);
color: #ffffff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .avatar-hover-tip {
opacity: 1;
}
}
.metric-config-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 12px;
.avatar-right-controls {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.metric-config-head {
.avatar-input-row {
display: flex;
justify-content: space-between;
gap: 6px;
align-items: center;
.metric-title {
font-size: 13px;
font-weight: 600;
color: #334155;
.action-picker-btn {
flex-shrink: 0;
}
}
.metric-inputs {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
.sub-tip-text {
font-size: 11px;
color: #94a3b8;
}
}
}
.sub-field {
display: flex;
align-items: center;
gap: 8px;
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.sub-label {
width: 56px;
font-size: 12px;
color: #64748b;
flex-shrink: 0;
}
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.mt-6 { margin-top: 6px; }
.mt-10 { margin-top: 10px; }
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.url-clear-icon {
font-size: 12px;
color: #94a3b8;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: #ef4444;
}
}
}
.metric-config-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
.metric-config-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.metric-title {
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
}
.metric-inputs {
.sub-field {
display: flex;
flex-direction: column;
gap: 4px;
.sub-label {
font-size: 11px;
color: #64748b;
}
}
}
}
.services-list {
display: flex;
flex-direction: column;
gap: 8px;
.service-config-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
.service-header-row {
display: flex;
align-items: center;
gap: 6px;
.service-idx {
font-size: 12px;
font-weight: 600;
color: #64748b;
width: 16px;
}
}
}
}
}
</style>

View File

@@ -1,20 +1,136 @@
<template>
<div class="panel-video">
<!-- 1. 媒体资源设置 -->
<div class="panel-section">
<div class="section-title">视频播放设置</div>
<div class="section-title">视频源与媒体文件</div>
<!-- 视频文件选择 -->
<div class="form-item">
<span class="item-label">视频文件 URL (.mp4)</span>
<el-input v-model="options.videoUrl" size="small" placeholder="请输入视频在线地址" clearable />
<div class="input-with-action">
<el-input
v-model="options.videoUrl"
size="small"
placeholder="请输入在线视频地址或点击右侧选择"
clearable
>
<template #prefix>
<el-icon class="field-icon"><VideoCamera /></el-icon>
</template>
</el-input>
<el-button
type="primary"
size="small"
class="action-picker-btn"
@click="handleSelectVideo"
>
<el-icon class="mr-4"><VideoCamera /></el-icon>
<span>选择视频</span>
</el-button>
</div>
<div class="source-status-tag" v-if="options.videoUrl">
<span class="status-dot"></span>
<span class="status-text" :title="options.videoUrl">已绑定视频: {{ getMediaName(options.videoUrl) }}</span>
<span class="status-clear-btn" @click="options.videoUrl = ''" title="清除视频">清空</span>
</div>
</div>
<!-- 视频封面图选择 -->
<div class="form-item mt-14">
<span class="item-label">视频封面图片 (海报/底图)</span>
<div class="picker-flex-wrap">
<!-- 缩略图预览与快捷替换 -->
<div
class="thumb-preview-box"
@click="handleSelectCover"
:title="options.coverUrl ? '点击更换封面图' : '点击选择封面图'"
>
<template v-if="options.coverUrl">
<img :src="options.coverUrl" alt="封面" class="preview-img" />
<span class="thumb-clear-btn" @click.stop="options.coverUrl = ''" title="清除封面">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="thumb-empty-icon"><Picture /></el-icon>
<span class="thumb-empty-tip">选图</span>
</template>
</div>
<!-- URL 输入框与选择图片按钮 -->
<div class="input-with-action flex-1">
<el-input
v-model="options.coverUrl"
size="small"
placeholder="请输入封面图 URL 或点击选择"
clearable
/>
<el-button
size="small"
class="action-picker-btn"
@click="handleSelectCover"
>
<el-icon class="mr-4"><Picture /></el-icon>
<span>选择图片</span>
</el-button>
</div>
</div>
</div>
</div>
<!-- 2. 点击跳转链接 -->
<div class="panel-section">
<div class="section-title">跳转与行动点 (CTA)</div>
<div class="form-item">
<span class="item-label">视频封面图片 URL</span>
<el-input v-model="options.coverUrl" size="small" placeholder="请输入视频封面图地址" clearable />
<span class="item-label">跳转链接 (选填,点击视频或行动点跳转)</span>
<div class="link-picker-wrap">
<el-input
v-model="options.url"
size="small"
placeholder="如 /goodsList?seckill=1 或完整网址"
clearable
>
<template #prefix>
<el-icon class="link-input-icon"><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
plain
size="small"
class="action-picker-btn link-btn"
@click="handleSelectLink"
>
<el-icon class="mr-4"><Link /></el-icon>
<span>选择链接</span>
</el-button>
</div>
<div class="link-current-tag" v-if="options.url">
<span class="tag-label">当前链接:</span>
<span class="tag-val" :title="options.url">{{ options.url }}</span>
</div>
</div>
</div>
<!-- 3. 播放与展示控制 -->
<div class="panel-section">
<div class="section-title">播放与展示控制</div>
<div class="form-item flex-row">
<span class="item-label">播放器高度 (px)</span>
<el-input-number
v-model="options.height"
:min="200"
:max="800"
:step="20"
size="small"
controls-position="right"
/>
</div>
<div class="form-item flex-row">
<span class="item-label">播放高度 (px)</span>
<el-input-number v-model="options.height" :min="200" :max="800" size="small" controls-position="right" />
<span class="item-label">显示播放控制条</span>
<el-switch v-model="options.controls" size="small" />
</div>
<div class="form-item flex-row">
@@ -31,6 +147,10 @@
</template>
<script setup>
import { inject } from 'vue';
import { VideoCamera, Picture, Link, Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
@@ -41,6 +161,86 @@ const props = defineProps({
required: true
}
});
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openVideoPicker = inject('openVideoPicker', null);
const openLinkPicker = inject('openLinkPicker', null);
// 提取 URL 中的文件名
function getMediaName(url) {
if (!url) return '';
try {
const cleanUrl = url.split('?')[0];
const parts = cleanUrl.split('/');
return parts[parts.length - 1] || url;
} catch (e) {
return url;
}
}
// 1. 选择视频文件 (.mp4)
function handleSelectVideo() {
if (openVideoPicker) {
openVideoPicker((url) => {
if (url) props.options.videoUrl = url;
});
} else {
ElMessageBox.prompt('请输入在线视频 (.mp4) URL 地址:', '选择视频文件', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.videoUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '视频地址不能为空'
})
.then(({ value }) => {
props.options.videoUrl = value.trim();
})
.catch(() => {});
}
}
// 2. 选择封面图片
function handleSelectCover() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) props.options.coverUrl = url;
});
} else {
ElMessageBox.prompt('请输入视频封面图 URL 地址:', '选择视频封面', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.coverUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '封面图地址不能为空'
})
.then(({ value }) => {
props.options.coverUrl = value.trim();
})
.catch(() => {});
}
}
// 3. 选择跳转链接
function handleSelectLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
if (url) props.options.url = url;
});
} else {
ElMessageBox.prompt('请输入跳转链接(如 /goodsList?seckill=1 或完整网址):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
props.options.url = value.trim();
})
.catch(() => {});
}
}
</script>
<style lang="scss" scoped>
@@ -50,6 +250,12 @@ const props = defineProps({
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-title {
font-size: 13px;
font-weight: 600;
@@ -57,6 +263,10 @@ const props = defineProps({
margin-bottom: 12px;
}
.mt-14 {
margin-top: 14px;
}
.form-item {
display: flex;
flex-direction: column;
@@ -72,8 +282,192 @@ const props = defineProps({
.item-label {
font-size: 12px;
color: #475569;
font-weight: 500;
}
}
}
.mr-4 {
margin-right: 4px;
}
.flex-1 {
flex: 1;
}
.field-icon {
color: #94a3b8;
}
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
.source-status-tag {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
border-radius: 4px;
padding: 3px 8px;
margin-top: 2px;
overflow: hidden;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
.status-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-clear-btn {
color: #dc2626;
cursor: pointer;
font-size: 11px;
padding-left: 4px;
flex-shrink: 0;
&:hover {
text-decoration: underline;
}
}
}
.picker-flex-wrap {
display: flex;
align-items: center;
gap: 8px;
.thumb-preview-box {
position: relative;
width: 58px;
height: 40px;
border-radius: 4px;
border: 1px dashed #cbd5e1;
background: #ffffff;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s;
&:hover {
border-color: #409eff;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-empty-icon {
font-size: 15px;
color: #94a3b8;
}
.thumb-empty-tip {
font-size: 10px;
color: #94a3b8;
line-height: 1;
margin-top: 2px;
}
.thumb-clear-btn {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.65);
color: #ffffff;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
}
.link-picker-wrap {
display: flex;
align-items: center;
gap: 6px;
.link-input-icon {
color: #94a3b8;
}
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
&.link-btn {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
&:hover {
background: #409eff;
color: #ffffff;
}
}
}
}
.link-current-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 4px;
padding: 3px 8px;
margin-top: 4px;
overflow: hidden;
.tag-label {
color: #3b82f6;
font-weight: 500;
flex-shrink: 0;
}
.tag-val {
color: #1e40af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -88,7 +88,7 @@
type="text"
v-model="row.rowStyle.height"
class="dim-input"
placeholder="40"
placeholder="默认"
/>
</div>
<div class="dimension-item">

View File

@@ -211,6 +211,20 @@
import { ref, computed } from 'vue';
import {
House, Compass, Picture, ShoppingBag, Ticket, Document, Menu, Bottom as BottomIcon,
List, Grid, Fold, PictureRounded, Medal, Aim, FullScreen, Files, VideoCamera,
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell,
Search, User, Rank
} from '@element-plus/icons-vue';
const iconMap = {
House, Compass, Picture, ShoppingBag, Ticket, Document, Menu, Bottom: BottomIcon,
List, Grid, Fold, PictureRounded, Medal, Aim, FullScreen, Files, VideoCamera,
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell,
Search, User, Rank
};
import RowConfigPanel from './RowConfigPanel.vue';
import ComponentConfigPanel from './ComponentConfigPanel.vue';
@@ -367,19 +381,11 @@ const currentSelectedTitle = computed(() => {
const currentSelectedIcon = computed(() => {
if (props.selectedComponent) {
if (props.selectedComponent.icon) return props.selectedComponent.icon;
const def = COMPONENT_DEFINITIONS.find(d => d.type === props.selectedComponent.type);
return def?.icon || 'Grid';
const iconName = props.selectedComponent.icon || COMPONENT_DEFINITIONS.find(d => d.type === props.selectedComponent.type)?.icon;
return iconMap[iconName] || Grid;
}
return 'Rank';
return Rank;
});

View File

@@ -93,6 +93,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'topBar',
defaultRowHeight: 40,
name: '快捷栏',
@@ -130,7 +131,7 @@ export const COMPONENT_DEFINITIONS = [
radius: [0, 0, 0, 0],
padding: [0, 16, 0, 16]
padding: [0, 0, 0, 0]
}
@@ -139,6 +140,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'searchHeader',
defaultRowHeight: 120,
name: '搜索头部',
@@ -152,7 +154,10 @@ export const COMPONENT_DEFINITIONS = [
logoUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/logo/logo.png',
placeholder: '搜索品牌或商品',
placeholder: '请输入商品名称',
showSearchTypes: true,
defaultSearchType: 'goods',
storePlaceholder: '请输入店铺名称',
hotWords: ['手机', '数码家电', '秋季上新', '电脑整机', '智能手表'],
@@ -180,7 +185,7 @@ export const COMPONENT_DEFINITIONS = [
radius: [0, 0, 0, 0],
padding: [16, 20, 16, 20]
padding: [16, 0, 16, 0]
}
@@ -189,6 +194,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'footerNav',
defaultRowHeight: 450,
name: '页脚导航',
@@ -257,6 +263,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'categoryNav',
defaultRowHeight: 48,
name: '类目导航',
group: 'navigation',
icon: 'List',
@@ -292,6 +299,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'channelGrid',
defaultRowHeight: 120,
name: '频道宫格',
group: 'navigation',
icon: 'Grid',
@@ -318,15 +326,17 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'categoryMegaMenu',
name: '类目弹层',
defaultRowHeight: 320,
name: '商品分类',
group: 'navigation',
icon: 'Fold',
description: '垂直分类菜单与多级展开弹层',
defaultOptions: {
title: '分类',
title: '商品分类',
source: 'system',
textColor: '#111927',
fontSize: 14,
activeColor: '#ea580c',
activeColor: '#409EFF',
categories: [
{
name: '精选频道',
@@ -360,8 +370,8 @@ export const COMPONENT_DEFINITIONS = [
},
defaultComponentStyle: {
widthRatio: '16.67%',
backgroundColor: 'transparent',
radius: [0, 0, 0, 0],
backgroundColor: '#ffffff',
radius: [8, 8, 8, 8],
padding: [0, 0, 0, 0]
}
},
@@ -373,6 +383,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'carousel',
defaultRowHeight: 320,
name: '轮播图',
group: 'media',
icon: 'PictureRounded',
@@ -385,7 +396,7 @@ export const COMPONENT_DEFINITIONS = [
showIndicator: true,
fitMode: 'contain',
aspectRatio: '2.5:1',
height: 380,
height: 420,
list: [
{
img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png',
@@ -411,8 +422,8 @@ export const COMPONENT_DEFINITIONS = [
},
defaultComponentStyle: {
widthRatio: '66.67%',
backgroundColor: 'transparent',
radius: [0, 0, 0, 0],
backgroundColor: '#ffffff',
radius: [8, 8, 8, 8],
padding: [0, 0, 0, 0]
}
},
@@ -420,6 +431,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'brandWall',
defaultRowHeight: 220,
name: '品牌图',
group: 'media',
icon: 'Medal',
@@ -444,6 +456,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'hotZone',
defaultRowHeight: 750,
name: '热区图',
group: 'media',
icon: 'Aim',
@@ -488,6 +501,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'freeLayout',
defaultRowHeight: 450,
name: '自由布局',
@@ -518,6 +532,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'promoGroup',
defaultRowHeight: 180,
name: '活动卡组',
@@ -552,6 +567,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'video',
defaultRowHeight: 360,
name: '视频',
group: 'media',
icon: 'VideoCamera',
@@ -559,6 +575,7 @@ export const COMPONENT_DEFINITIONS = [
defaultOptions: {
videoUrl: '',
coverUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png',
url: '',
autoPlay: false,
loop: false,
controls: true,
@@ -580,6 +597,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsList',
defaultRowHeight: 980,
name: '商品列表',
@@ -709,6 +727,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'gridFloor',
defaultRowHeight: 560,
name: '宫格楼层',
@@ -824,6 +843,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsGrid',
defaultRowHeight: 420,
name: '商品宫格',
group: 'commerce',
icon: 'Menu',
@@ -864,6 +884,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsCard',
defaultRowHeight: 360,
name: '商品卡片',
@@ -874,6 +895,8 @@ export const COMPONENT_DEFINITIONS = [
description: '精选单品爆款特写大卡片',
defaultOptions: {
type: 'goodsCard',
showSales: false,
goodsId: '1',
goodsInfo: { id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
customTitle: '年度年度旗舰 · 科技美学',
@@ -899,6 +922,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'bannerFloor',
defaultRowHeight: 560,
name: '横幅楼层',
@@ -940,6 +964,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsBar',
defaultRowHeight: 280,
name: '商品条',
@@ -976,6 +1001,7 @@ export const COMPONENT_DEFINITIONS = [
},
{type: 'channelFloor',
defaultRowHeight: 620,
name: '频道楼层',
@@ -1019,6 +1045,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'recommendFeed',
defaultRowHeight: 1270,
name: '推荐商品',
@@ -1059,6 +1086,7 @@ export const COMPONENT_DEFINITIONS = [
// 5. 营销 (2个)
{
type: 'userBenefits',
defaultRowHeight: 320,
name: '用户权益',
group: 'marketing',
icon: 'User',
@@ -1092,6 +1120,7 @@ export const COMPONENT_DEFINITIONS = [
},
{
type: 'couponList',
defaultRowHeight: 160,
name: '优惠券',
@@ -1131,6 +1160,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'titleBar',
defaultRowHeight: 48,
name: '标题栏',
@@ -1165,6 +1195,7 @@ export const COMPONENT_DEFINITIONS = [
},
{type: 'articleContent',
defaultRowHeight: '',
name: '文章内容',
group: 'content',
icon: 'Document',
@@ -1201,6 +1232,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'noticeBar',
defaultRowHeight: 40,
name: '公告快讯',
@@ -1234,7 +1266,7 @@ export const COMPONENT_DEFINITIONS = [
export const DEFAULT_ROW_STYLE = {
width: 1920,
height: 40,
height: '',
gap: 0,
margin: [0, 0, 0, 0],
padding: [0, 0, 0, 0],
@@ -1250,27 +1282,32 @@ export const DEFAULT_ROW_STYLE = {
*/
export function createRow(component = null) {
const rowId = `row_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const components = [];
let rowHeight = '';
if (component) {
components.push(createComponent(component));
if (component.type && !component.componentStyle) {
// 传入的是组件定义 def
components.push(createComponent(component));
if (component.defaultRowHeight !== undefined && component.defaultRowHeight !== null) {
rowHeight = component.defaultRowHeight;
}
} else {
// 传入的是已有组件实例
components.push(component);
const def = COMPONENT_DEFINITIONS.find(d => d.type === component.type);
if (def?.defaultRowHeight !== undefined && def?.defaultRowHeight !== null) {
rowHeight = def.defaultRowHeight;
}
}
}
return {
id: rowId,
rowStyle: { ...DEFAULT_ROW_STYLE },
rowStyle: { ...DEFAULT_ROW_STYLE, height: rowHeight },
components
};
}

View File

@@ -143,7 +143,7 @@
<el-icon :size="20" class="comp-icon">
<component :is="iconMap[element.icon] || 'Grid'" />
<component :is="iconMap[element.icon] || Grid" />
</el-icon>
@@ -329,17 +329,21 @@
v-if="resolveCanvasTemplate(comp.type)"
:component="comp"
:options="comp.options || {}"
:component-style="comp.componentStyle || {}"
:row-style="row.rowStyle || {}"
/>
<div v-else class="comp-mock-placeholder">
<el-icon :size="24" class="mock-icon">
<component :is="iconMap[getCompIcon(comp.type)] || 'Grid'" />
<component :is="iconMap[getCompIcon(comp.type)] || Grid" />
</el-icon>
@@ -435,7 +439,7 @@
@selectedGoodsData="handleGlobalGoodsSelected"
/>
<!-- 全局图片选择器弹窗 -->
<!-- 全局媒体选择器弹窗(图片/视频) -->
<el-dialog
v-model="picModelFlag"
width="1160px"
@@ -443,7 +447,7 @@
class="pc-image-picker-dialog"
append-to-body
destroy-on-close
title="选择图片"
:title="currentPickerType === 'video' ? '选择视频' : '选择图片'"
@close="handleImageDialogClose"
>
<ossManage
@@ -451,8 +455,8 @@
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
:only-image="true"
media-type="IMAGE"
:only-image="currentPickerType !== 'video'"
:media-type="currentPickerType === 'video' ? 'VIDEO' : 'IMAGE'"
ref="ossManageRef"
/>
</el-dialog>
@@ -478,7 +482,7 @@ import {
List, Grid, Fold, PictureRounded, Medal, Aim, FullScreen, VideoCamera,
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell, User
} from '@element-plus/icons-vue';
@@ -516,7 +520,7 @@ const iconMap = {
List, Grid, Fold, PictureRounded, Medal, Aim, FullScreen, Files, VideoCamera,
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell
Goods, Postcard, Reading, DArrowRight, CollectionTag, Star, Edit, Bell, User, Search
};
@@ -795,55 +799,26 @@ function clearSelection() {
// 规范化:确保每个组件都是独立的一行(一个组件是一行)
function normalizeRows(list) {
if (!Array.isArray(list)) return [];
const normalized = [];
list.forEach(r => {
if (r.components && r.components.length > 1) {
r.components.forEach((c, idx) => {
const singleRow = createRow();
singleRow.id = idx === 0 ? r.id : `row_${Date.now()}_${Math.random().toString(36).substring(2, 6)}_${idx}`;
singleRow.rowStyle = JSON.parse(JSON.stringify(r.rowStyle || DEFAULT_ROW_STYLE));
singleRow.components = [c];
normalized.push(singleRow);
});
} else {
normalized.push(r);
if (!r) return;
if (r.rowStyle && (r.rowStyle.height === 40 || r.rowStyle.height === "40")) {
r.rowStyle.height = "";
}
normalized.push(r);
});
return normalized;
}
// 拖拽克隆生成行(一个组件是一行)
function handleCloneAsRow(def) {
markDirty();
const newComp = createComponent(def);
const newRow = createRow();
const newRow = createRow(def);
newRow.components = [newComp];
return newRow;
}
// 点击左侧「+」或组件卡片:追加新组件(支持同排自动合并)
@@ -857,15 +832,13 @@ function handleAppendComponent(def) {
// 1. 若当前选中的是无组件的空白行,放入该行
if (selectedRow.value && (!selectedRow.value.components || selectedRow.value.components.length === 0)) {
selectedRow.value.components = [newComp];
if (def.defaultRowHeight !== undefined && def.defaultRowHeight !== null && def.defaultRowHeight !== '' && (!selectedRow.value.rowStyle.height || selectedRow.value.rowStyle.height === '')) {
selectedRow.value.rowStyle.height = def.defaultRowHeight;
}
selectedCompId.value = newComp.id;
configPanelRef.value?.selectComponentTab();
return;
}
// 2. 若当前选中的行已有组件且宽度未满 100%(例如已有 50% 频道宫格,再点击则同行放置)
@@ -894,9 +867,7 @@ function handleAppendComponent(def) {
}
// 3. 否则新建独立行
const newRow = createRow();
const newRow = createRow(def);
newRow.components = [newComp];
if (selectedRowId.value) {
@@ -943,10 +914,14 @@ function openLinkPicker(callback) {
}
}
function openGoodsPicker(callback) {
function openGoodsPicker(callback, initialSelected = []) {
pendingGoodsCallback.value = callback;
if (liliDialogRef.value) {
liliDialogRef.value.clearGoodsSelected();
if (initialSelected && Array.isArray(initialSelected) && initialSelected.length) {
liliDialogRef.value.goodsData = initialSelected.map(item => ({ ...item }));
} else {
liliDialogRef.value.clearGoodsSelected();
}
liliDialogRef.value.goodsFlag = true;
liliDialogRef.value.flag = true;
}
@@ -1021,8 +996,10 @@ provide('openGoodsPicker', openGoodsPicker);
const picModelFlag = ref(false);
const ossManageRef = ref(null);
const pendingImageCallback = ref(null);
const currentPickerType = ref('pic');
function openImagePicker(callback) {
currentPickerType.value = 'pic';
pendingImageCallback.value = callback;
picModelFlag.value = true;
nextTick(() => {
@@ -1036,6 +1013,21 @@ function openImagePicker(callback) {
});
}
function openVideoPicker(callback) {
currentPickerType.value = 'video';
pendingImageCallback.value = callback;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
ossManageRef.value.fileType = 'video';
if (typeof ossManageRef.value.changeFileType === 'function') {
ossManageRef.value.changeFileType();
}
}
});
}
function handleGlobalImageSelected(val) {
picModelFlag.value = false;
let url = '';
@@ -1056,6 +1048,7 @@ function handleImageDialogClose() {
}
provide('openImagePicker', openImagePicker);
provide('openVideoPicker', openVideoPicker);
function handleAddNewRow() {
markDirty();
@@ -1090,6 +1083,19 @@ function moveRow(index, offset) {
}
function moveRowTo(fromIndex, toIndex) {
if (fromIndex === undefined || fromIndex === null || fromIndex < 0) return;
if (toIndex === undefined || toIndex === null || toIndex < 0) return;
const list = pageDocument.value.list;
if (!Array.isArray(list) || fromIndex >= list.length || toIndex >= list.length) return;
if (fromIndex === toIndex) return;
markDirty();
const [removed] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, removed);
selectedRowId.value = removed.id;
}
function moveRowToExtreme(index, direction) {
markDirty();
@@ -1211,7 +1217,7 @@ function handleDuplicateComp(row, cIndex, comp) {
selectedCompId.value = clone.id;
Message.success('已复制到当前行(同行展示)');
} else {
const newRow = createRow();
const newRow = createRow(clone);
newRow.components = [clone];
const rIdx = pageDocument.value.list.findIndex(r => r.id === row.id);
if (rIdx !== -1) {
@@ -1273,7 +1279,9 @@ function getRowWrapperStyle(style) {
if (style.height !== undefined && style.height !== '') {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
s.minHeight = /^\d+$/.test(h) ? `${h}px` : h;
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
}
}
@@ -1311,15 +1319,24 @@ function getRowWrapperStyle(style) {
}
function getRowFlexStyle(style) {
if (!style) return { display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', width: '100%' };
if (!style) return { display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', width: '100%', height: '100%' };
const s = {
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: style.alignItems || 'stretch',
justifyContent: style.justifyContent || 'flex-start',
width: '100%'
width: '100%',
height: '100%'
};
if (style.height !== undefined && style.height !== '') {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
}
}
if (style.gap !== undefined && style.gap !== '') {
const g = String(style.gap).trim();
if (g && Number(g) > 0) {
@@ -1336,6 +1353,15 @@ function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) {
if (!style) return {};
const s = {};
if (rowStyle?.height !== undefined && rowStyle?.height !== '') {
const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal;
s.minHeight = rhVal;
}
}
const gap = Number(rowStyle?.gap) || 0;
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;
@@ -2035,6 +2061,10 @@ onUnmounted(() => {
box-sizing: border-box;
display: flex;
flex-direction: column;
border: 2px solid transparent;
transition: border-color 0.2s;
@@ -2241,7 +2271,7 @@ onUnmounted(() => {
box-sizing: border-box;
overflow: hidden;
overflow: visible;
flex: 1;
@@ -2251,6 +2281,13 @@ onUnmounted(() => {
height: 100%;
> * {
flex: 1;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.comp-mock-placeholder {
display: flex;

View File

@@ -22,7 +22,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 40
},
"components": [
{
@@ -77,7 +78,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
12,
0
]
],
"height": 120
},
"components": [
{
@@ -99,7 +101,10 @@ export const DOUBLE11_PAGE_DOCUMENT = {
"华为Mate 70",
"五粮液普五",
"耐克碳板跑鞋"
]
],
"showSearchTypes": true,
"defaultSearchType": "goods",
"storePlaceholder": "请输入店铺名称"
}
}
]
@@ -120,7 +125,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -193,7 +199,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
],
"justifyContent": "space-between",
"alignItems": "stretch",
"gap": 12
"gap": 12,
"height": 320
},
"components": [
{
@@ -366,7 +373,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
4,
12
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 40
},
"components": [
{
@@ -427,7 +435,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
14
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 120
},
"components": [
{
@@ -532,7 +541,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -569,7 +579,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 160
},
"components": [
{
@@ -634,7 +645,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 750
},
"components": [
{
@@ -711,7 +723,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 180
},
"components": [
{
@@ -771,7 +784,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0
],
"justifyContent": "space-between"
"justifyContent": "space-between",
"height": 360
},
"components": [
{
@@ -874,7 +888,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
14,
20
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 450
},
"components": [
{
@@ -924,7 +939,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -962,7 +978,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 560
},
"components": [
{
@@ -1043,7 +1060,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 560
},
"components": [
{
@@ -1121,7 +1139,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 620
},
"components": [
{
@@ -1190,7 +1209,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1203,7 +1223,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
},
"options": {
"title": "双11爆款必买热销榜",
"subtitle": "TOP RANKINGS · 亿万买家真实下单好评热选",
"subtitle": "TOP RANKINGS · 平台高人气甄选热卖",
"tag": "实时榜单",
"moreText": "查看完整榜单 >",
"moreUrl": "/goodsList"
@@ -1228,7 +1248,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 420
},
"components": [
{
@@ -1343,7 +1364,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1381,7 +1403,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 360
},
"components": [
{
@@ -1395,6 +1418,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
"options": {
"videoUrl": "https://vjs.zencdn.net/v/oceans.mp4",
"coverUrl": "https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png",
"url": "/goodsList?brand=1",
"title": "双11全球狂欢发布盛典 · 年度旗舰好物真实开箱测评",
"autoplay": false
}
@@ -1417,7 +1441,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1455,7 +1480,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 220
},
"components": [
{
@@ -1513,7 +1539,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1551,7 +1578,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
20,
24
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": ""
},
"components": [
{
@@ -1588,7 +1616,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1626,7 +1655,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 980
},
"components": [
{
@@ -1740,7 +1770,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0,
0
]
],
"height": 48
},
"components": [
{
@@ -1778,7 +1809,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16,
16
],
"borderRadius": "8px"
"borderRadius": "8px",
"height": 1270
},
"components": [
{
@@ -1893,7 +1925,8 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
24,
0
]
],
"height": 450
},
"components": [
{

View File

@@ -57,6 +57,10 @@ const props = defineProps({
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
@@ -78,10 +82,10 @@ const slidesList = computed(() => {
}));
}
return [
{ title: '出游囤好货', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png', url: '/goodsList?seckill=1' },
{ title: '科技新品', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_2.png', url: '/goodsList?category=1' },
{ title: '原产地直采', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_3.png', url: '/goodsList?category=2' },
{ title: '大牌美妆', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_4.png', url: '/goodsList?category=3' }
{ title: '潮流数码', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png', url: '/goodsList?category=1' },
{ title: '奢宠美妆', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_2.png', url: '/goodsList?category=2' },
{ title: '运动潮流', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_3.png', url: '/goodsList?category=3' },
{ title: '居家生活', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_4.png', url: '/goodsList?category=4' }
];
});
@@ -89,41 +93,25 @@ const currentSlide = computed(() => {
return slidesList.value[currentIdx.value] || slidesList.value[0];
});
function getAspectRatioCss(ratio) {
if (ratio === '16:9') return '16 / 9';
if (ratio === '2:1') return '2 / 1';
if (ratio === '3:1') return '3 / 1';
if (ratio === 'custom') return null;
return '2.5 / 1';
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const fit = props.options.fitMode || 'contain';
const fit = props.options.fitMode || 'cover';
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
const styleObj = {
borderRadius: Array.isArray(s.radius)
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
backgroundColor: s.backgroundColor || '#ffffff',
'--carousel-fit': fit
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
'--carousel-fit': fit,
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
if (props.options.height) {
if (props.options.aspectRatio === 'custom' && props.options.height) {
styleObj.height = `${props.options.height}px`;
styleObj.aspectRatio = 'auto';
} else {
const ratio = props.options.aspectRatio || '2.5:1';
const ratioCss = getAspectRatioCss(ratio);
if (ratioCss) {
styleObj.aspectRatio = ratioCss;
styleObj['--carousel-ratio'] = ratioCss;
} else {
styleObj.height = `${s.height || 420}px`;
styleObj.aspectRatio = 'auto';
}
styleObj.minHeight = `${props.options.height}px`;
}
return styleObj;
});
@@ -158,6 +146,7 @@ function stopTimer() {
}
function resetTimer() {
stopTimer();
startTimer();
}
@@ -180,6 +169,7 @@ onBeforeUnmount(() => {
<style lang="scss" scoped>
.tpl-carousel {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
user-select: none;
@@ -188,10 +178,13 @@ onBeforeUnmount(() => {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
display: flex;
flex-direction: column;
.carousel-container {
width: 100%;
height: 100%;
flex: 1;
position: relative;
display: flex;
align-items: center;
@@ -208,81 +201,68 @@ onBeforeUnmount(() => {
.slide-img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: var(--carousel-fit, contain) !important;
object-position: center;
object-fit: var(--carousel-fit, cover);
display: block;
}
}
// 切换动效
.carousel-fade-enter-active,
.carousel-fade-leave-active {
transition: opacity 0.4s ease;
}
.carousel-fade-enter-from,
.carousel-fade-leave-to {
opacity: 0;
}
// 左右箭头按钮
.arrow-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.35);
background: rgba(0, 0, 0, 0.35);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s ease;
z-index: 10;
backdrop-filter: blur(2px);
transition: all 0.2s;
&:hover {
background: rgba(15, 23, 42, 0.65);
background: rgba(0, 0, 0, 0.65);
transform: translateY(-50%) scale(1.08);
}
&.prev-btn {
left: 16px;
left: 12px;
}
&.next-btn {
right: 16px;
right: 12px;
}
}
// 底部指示点
.carousel-indicators {
position: absolute;
bottom: 14px;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
z-index: 10;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.18);
border-radius: 10px;
backdrop-filter: blur(2px);
.indicator-dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: all 0.25s;
&.active {
width: 22px;
border-radius: 5px;
background: #ea580c;
width: 16px;
border-radius: 3px;
background: #409EFF;
}
&:hover:not(.active) {
@@ -292,42 +272,33 @@ onBeforeUnmount(() => {
}
}
&.is-compact {
&.is-mini {
.arrow-btn {
width: 26px;
height: 26px;
font-size: 12px;
&.prev-btn { left: 8px; }
&.next-btn { right: 8px; }
width: 24px;
height: 24px;
font-size: 13px;
}
.carousel-indicators {
bottom: 8px;
gap: 5px;
gap: 4px;
.indicator-dot {
width: 6px;
height: 6px;
&.active { width: 14px; }
}
}
}
&.is-mini {
.arrow-btn {
width: 20px;
height: 20px;
font-size: 10px;
&.prev-btn { left: 4px; }
&.next-btn { right: 4px; }
}
.carousel-indicators {
bottom: 4px;
gap: 3px;
.indicator-dot {
width: 4px;
height: 4px;
&.active { width: 10px; }
width: 5px;
height: 5px;
&.active {
width: 12px;
}
}
}
}
}
.carousel-fade-enter-active,
.carousel-fade-leave-active {
transition: opacity 0.35s ease;
}
.carousel-fade-enter-from,
.carousel-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,32 +1,83 @@
<template>
<div class="tpl-category-mega-menu" :style="customStyle">
<!-- 顶部标题:分类(同万米设计,纯净大方) -->
<div
class="tpl-category-mega-menu"
:class="{ 'is-hovering': panelVisible && activeChildren.length }"
:style="customStyle"
@mouseleave="handleMouseLeave"
>
<!-- 顶部标题:分类 / 商品分类 -->
<div class="menu-header">
<span class="header-title">{{ options.title || '分类' }}</span>
<span class="header-title">{{ options.title || '商品分类' }}</span>
</div>
<!-- 垂直一级类目列表(8项均匀铺满整个高度) -->
<!-- 垂直一级类目列表 -->
<div class="category-list">
<div
v-for="(cat, idx) in categoriesList"
:key="idx"
v-for="(cat, idx) in displayCategories"
:key="cat.id || idx"
class="cat-item"
:class="{ 'is-active': activeIndex === idx && panelVisible }"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#409EFF',
'--font-size': `${options.fontSize || 13}px`
}"
@mouseenter="handleMouseEnter(idx, cat)"
>
<span class="cat-name">{{ cat.name }}</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
<!-- 右侧悬浮超级大面板 (Mega Menu 仿真预览) -->
<transition name="fade-flyout">
<div
v-if="panelVisible && activeChildren.length"
class="mega-flyout-panel"
@mouseenter="handlePanelEnter"
@mouseleave="handleMouseLeave"
>
<div class="flyout-top-tags" v-if="activeChildren && activeChildren.length">
<span
v-for="(sub, sIdx) in activeChildren.slice(0, 8)"
:key="sub.id || sIdx"
class="top-tag-pill"
>
{{ sub.name }}
<el-icon class="pill-arrow"><ArrowRight /></el-icon>
</span>
</div>
<div class="flyout-content-scroll">
<div
v-for="(sub, sIdx) in activeChildren"
:key="sub.id || sIdx"
class="cat-row"
>
<div class="lvl2-title">
<span class="lvl2-name">{{ sub.name }}</span>
<el-icon class="lvl2-arrow"><ArrowRight /></el-icon>
</div>
<div class="lvl3-tags-wrap">
<span
v-for="(leaf, lIdx) in (sub.children || [])"
:key="leaf.id || lIdx"
class="lvl3-tag"
>
{{ leaf.name }}
</span>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { ArrowRight } from '@element-plus/icons-vue';
import { getAllCategoryList } from '@/api/goods.js';
const props = defineProps({
options: {
@@ -36,41 +87,150 @@ const props = defineProps({
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const defaultCategories = [
{ id: '1', name: '潮流数码' },
{ id: '2', name: '奢宠美妆' },
{ id: '3', name: '智能家电' },
{ id: '4', name: '服饰鞋包' },
{ id: '5', name: '运动潮流' },
{ id: '6', name: '精品生鲜' },
{ id: '7', name: '居家生活' },
{ id: '8', name: '母婴玩具' }
{
id: '1',
name: '电脑办公',
children: [
{ id: '1-1', name: '手机通讯', children: [{ name: '手机' }, { name: '游戏手机' }, { name: '老人机' }] },
{ id: '1-2', name: '电脑整机', children: [{ name: '笔记本' }, { name: '台式机' }, { name: '平板电脑' }] }
]
},
{
id: '2',
name: '家用电器',
children: [
{ id: '2-1', name: '大家电', children: [{ name: '电视' }, { name: '冰箱' }, { name: '洗衣机' }, { name: '空调' }] },
{ id: '2-2', name: '厨房小电', children: [{ name: '电饭煲' }, { name: '微波炉' }, { name: '咖啡机' }] }
]
},
{
id: '3',
name: '服装鞋包',
children: [
{ id: '3-1', name: '女装', children: [{ name: '连衣裙' }, { name: '衬衫' }, { name: '羽绒服' }] },
{ id: '3-2', name: '男装', children: [{ name: 'T恤' }, { name: '夹克' }, { name: '牛仔裤' }] }
]
},
{
id: '4',
name: '食品生鲜',
children: [
{ id: '4-1', name: '生鲜水果', children: [{ name: '苹果' }, { name: '车厘子' }, { name: '奇异果' }] },
{ id: '4-2', name: '休闲零食', children: [{ name: '坚果炒货' }, { name: '饼干蛋糕' }, { name: '糖果果冻' }] }
]
},
{ id: '5', name: '精品箱包' },
{ id: '6', name: '美妆个护' },
{ id: '7', name: '运动户外' },
{ id: '8', name: '家居家装' }
];
const categoriesList = computed(() => {
if (Array.isArray(props.options?.categories) && props.options.categories.length) {
return props.options.categories.map(c => {
const rawName = c.name || c.title || '';
const cleanName = rawName.includes('/') ? rawName.split('/')[0].trim() : rawName;
return {
...c,
name: cleanName
};
});
const systemCategories = ref([]);
const activeIndex = ref(-1);
const activeCategory = ref(null);
const panelVisible = ref(false);
const isCustomSource = computed(() => {
if (props.options?.source === 'system') return false;
if (props.options?.source === 'custom') {
if (Array.isArray(props.options?.categories) && props.options.categories.length === 4) {
const names = props.options.categories.map(c => c.name || c.title || '');
const isPlaceholder = names.some(n => n.includes('精选')) &&
names.some(n => n.includes('新品')) &&
names.some(n => n.includes('企业')) &&
names.some(n => n.includes('领券'));
if (isPlaceholder) return false;
}
return true;
}
return false;
});
const displayCategories = computed(() => {
if (isCustomSource.value && Array.isArray(props.options?.categories) && props.options.categories.length) {
return props.options.categories.map(c => ({
...c,
name: c.name || c.title || ''
}));
}
if (systemCategories.value && systemCategories.value.length) {
return systemCategories.value;
}
return defaultCategories;
});
const activeChildren = computed(() => {
if (!activeCategory.value) return [];
if (Array.isArray(activeCategory.value.children) && activeCategory.value.children.length) {
return activeCategory.value.children;
}
if (Array.isArray(activeCategory.value.subCategories) && activeCategory.value.subCategories.length) {
return activeCategory.value.subCategories.map((s, idx) => ({
id: s.id || `sub_${idx}`,
name: s.name,
children: (s.items || []).map((it, iIdx) => ({
id: `leaf_${idx}_${iIdx}`,
name: typeof it === 'string' ? it : it.name
}))
}));
}
return [];
});
function handleMouseEnter(idx, cat) {
activeIndex.value = idx;
activeCategory.value = cat;
if (cat.children && cat.children.length) {
panelVisible.value = true;
} else if (cat.subCategories && cat.subCategories.length) {
panelVisible.value = true;
} else {
panelVisible.value = false;
}
}
function handlePanelEnter() {
panelVisible.value = true;
}
function handleMouseLeave() {
panelVisible.value = false;
activeIndex.value = -1;
activeCategory.value = null;
}
function fetchCategories() {
getAllCategoryList(0).then(res => {
if (res.success && Array.isArray(res.result) && res.result.length) {
systemCategories.value = res.result;
}
}).catch(() => {
// 降级使用默认真实分类
});
}
onMounted(() => {
fetchCategories();
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
return {
backgroundColor: s.backgroundColor || '#ffffff',
borderRadius: Array.isArray(s.radius)
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
: '8px',
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
});
</script>
@@ -79,7 +239,6 @@ const customStyle = computed(() => {
.tpl-category-mega-menu {
width: 100%;
height: 100%;
min-height: 420px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
@@ -87,16 +246,23 @@ const customStyle = computed(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
user-select: none;
overflow: hidden;
position: relative;
overflow: visible;
&.is-hovering {
z-index: 1050;
}
.menu-header {
padding: 14px 16px 6px;
height: 42px;
padding: 0 16px;
display: flex;
align-items: center;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
.header-title {
font-size: 15px;
font-size: 14px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.5px;
@@ -107,15 +273,19 @@ const customStyle = computed(() => {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 2px 0 10px;
justify-content: space-evenly;
padding: 4px 0 8px;
box-sizing: border-box;
min-height: 0;
overflow-y: auto;
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
padding: 6px 16px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
.cat-name {
@@ -128,14 +298,15 @@ const customStyle = computed(() => {
}
.arrow-icon {
font-size: 12px;
font-size: 11px;
color: #94a3b8;
transition: transform 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
&:hover {
background: #fff7ed;
&:hover,
&.is-active {
background: #ecf5ff;
.cat-name {
color: var(--active-color, #409EFF);
@@ -149,5 +320,124 @@ const customStyle = computed(() => {
}
}
}
.mega-flyout-panel {
position: absolute;
top: 0;
left: 100%;
width: 780px;
min-height: 100%;
max-height: 520px;
background: #ffffff;
z-index: 1100;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #e2e8f0;
border-left: none;
border-radius: 0 8px 8px 0;
padding: 16px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
.flyout-top-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 12px;
flex-shrink: 0;
.top-tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f8fafc;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
color: #334155;
cursor: default;
.pill-arrow {
font-size: 10px;
color: #94a3b8;
}
}
}
.flyout-content-scroll {
flex: 1;
overflow-y: auto;
padding-right: 6px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.cat-row {
display: flex;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px dashed #f1f5f9;
&:last-child {
border-bottom: none;
}
.lvl2-title {
width: 96px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
color: #1e293b;
font-weight: 600;
font-size: 12px;
padding-top: 1px;
.lvl2-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lvl2-arrow {
font-size: 10px;
color: #94a3b8;
}
}
.lvl3-tags-wrap {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
.lvl3-tag {
font-size: 12px;
color: #64748b;
line-height: 1.6;
}
}
}
}
}
}
.fade-flyout-enter-active,
.fade-flyout-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-flyout-enter-from,
.fade-flyout-leave-to {
opacity: 0;
transform: translateX(-4px);
}
</style>

View File

@@ -462,11 +462,11 @@ const customStyle = computed(() => {
flex: 1;
.goods-name {
font-size: 12px;
font-size: 14px;
color: #1e293b;
font-weight: 500;
line-height: 1.4;
height: 34px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;

View File

@@ -5,6 +5,9 @@
<div class="title-left">
<span class="main-title">{{ options.title }}</span>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
<span class="category-tag-badge" v-if="options.dataSource === 'category' && (options.categoryName || options.categoryId)">
🏷️ {{ options.categoryName || `分类: ${options.categoryId}` }}
</span>
</div>
<span class="more-link" v-if="options.showMoreLink !== false">
{{ options.moreText || '查看更多' }} &gt;
@@ -32,11 +35,6 @@
{{ g.goodsName }}
</div>
<div class="meta-row" v-if="options.showSales !== false">
<span class="sales-text">已售 {{ 120 + idx * 85 }}+ 件</span>
<span class="rate-text">99%好评</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="symbol">¥</span>
@@ -93,6 +91,10 @@ const defaultMockGoods = [
{ id: '12', goodsName: '航空级铝镁合金静音万向轮拉杆登机箱 20英寸防刮耐磨', price: '499.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' }
];
const isGoodsCard = computed(() => {
return props.component?.type === 'goodsCard' || props.options?.type === 'goodsCard';
});
const actualColumns = computed(() => {
// 商品列表整行默认 6 列
let baseCols = Number(props.options?.columns) || 6;
@@ -126,7 +128,7 @@ const customStyle = computed(() => {
});
const sampleGoods = computed(() => {
if (Array.isArray(props.options.goods) && props.options.goods.length > 0) {
if (props.options.dataSource === 'manual' && Array.isArray(props.options.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options.goodsCount || (actualColumns.value * 2) || 12;
@@ -153,26 +155,30 @@ const sampleGoods = computed(() => {
gap: 10px;
.main-title {
font-size: 20px;
font-size: 18px;
font-weight: 700;
color: #1e293b;
}
.sub-title {
font-size: 13px;
font-size: 12px;
color: #94a3b8;
}
.category-tag-badge {
font-size: 11px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
}
.more-link {
font-size: 13px;
font-size: 12px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
@@ -187,22 +193,6 @@ const sampleGoods = computed(() => {
padding: 10px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.thumb-img {
transform: scale(1.05);
}
.add-cart-circle {
background: #e11d48;
color: #ffffff;
}
}
.card-thumb-wrap {
position: relative;
@@ -217,7 +207,6 @@ const sampleGoods = computed(() => {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.self-tag {
@@ -239,10 +228,11 @@ const sampleGoods = computed(() => {
flex: 1;
.goods-name {
font-size: 12px;
font-size: 14px;
font-weight: 500;
color: #1e293b;
line-height: 1.4;
height: 34px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@@ -302,12 +292,10 @@ const sampleGoods = computed(() => {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
}
}
</style>
</style>

View File

@@ -249,11 +249,11 @@ const customStyle = computed(() => {
flex: 1;
.cell-name {
font-size: 13px;
font-size: 14px;
color: #334155;
font-weight: 500;
line-height: 1.4;
height: 36px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;

View File

@@ -122,7 +122,7 @@ const posterTags = computed(() => {
});
const posterImg = computed(() => {
return props.options?.posterImg || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
return props.options?.bannerImg || props.options?.posterImg || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
});
const showPoster = computed(() => {
@@ -444,11 +444,11 @@ const customStyle = computed(() => {
flex: 1;
.goods-name-line {
font-size: 13px;
font-size: 14px;
color: #1e293b;
font-weight: 500;
line-height: 1.4;
height: 36px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;

View File

@@ -10,7 +10,13 @@
>
<!-- 左侧快讯徽章 -->
<div class="notice-badge-box">
<div class="badge-icon">
<img
v-if="options.badgeImg || options.icon"
:src="options.badgeImg || options.icon"
class="badge-custom-img"
alt="icon"
/>
<div v-else class="badge-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/>
</svg>
@@ -146,161 +152,146 @@ const customStyle = computed(() => {
align-items: center;
box-sizing: border-box;
user-select: none;
background: #ffffff;
border: 1px solid #eef2f6;
gap: 12px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
&.is-mini {
gap: 6px;
padding: 0 8px !important;
.notice-badge-box {
.badge-title, .badge-divider {
display: none;
}
}
}
&.is-compact {
gap: 8px;
padding: 0 10px !important;
.notice-badge-box {
.badge-divider {
display: none;
}
}
}
/* 左侧徽章 */
.notice-badge-box {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
flex-shrink: 0;
.badge-icon {
color: #ef4444;
color: #f97316;
display: flex;
align-items: center;
justify-content: center;
}
.badge-custom-img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.badge-title {
font-size: 14px;
font-weight: 800;
font-size: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
letter-spacing: -0.2px;
}
.badge-divider {
width: 1px;
height: 14px;
background: #e2e8f0;
margin-left: 6px;
background: #cbd5e1;
margin-left: 4px;
}
}
/* 中间快讯轮播视口 */
.notice-content-viewport {
flex: 1;
min-width: 0;
height: 40px;
overflow: hidden;
position: relative;
min-width: 0;
.notice-items-strip {
transition: transform 0.4s ease-in-out;
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
display: flex;
flex-direction: column;
.notice-strip-item {
height: 40px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
box-sizing: border-box;
.notice-item-tag {
font-size: 10px;
font-weight: 700;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
border-radius: 4px;
font-weight: 600;
flex-shrink: 0;
&.tag-new {
background: #fee2e2;
color: #dc2626;
}
&.tag-promo {
background: #ffedd5;
color: #ea580c;
}
&.tag-notice {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
&.tag-promo {
background: #fef2f2;
color: #ef4444;
border: 1px solid #fecaca;
}
&.tag-notice {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
&.tag-hot {
background: #fdf2f8;
color: #db2777;
border: 1px solid #fbcfe8;
}
}
.notice-item-title {
flex: 1;
min-width: 0;
font-size: 13px;
color: #334155;
overflow: hidden;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
transition: color 0.2s;
}
.notice-item-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
flex-shrink: 0;
margin-right: 4px;
}
}
}
}
/* 右侧更多 */
.notice-more-action {
flex-shrink: 0;
font-size: 12px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #ef4444;
}
}
/* 紧凑形态 (三分之一) */
&.is-compact {
gap: 8px;
.notice-badge-box {
.badge-title {
font-size: 13px;
}
.badge-divider {
margin-left: 2px;
}
}
.notice-content-viewport {
.notice-strip-item {
.notice-item-title {
font-size: 12px;
}
}
}
}
/* 极简微缩形态 (六分之一) */
&.is-mini {
gap: 4px;
padding: 0 8px !important;
.notice-badge-box {
.badge-title {
display: none;
}
.badge-divider {
display: none;
}
}
.notice-content-viewport {
.notice-strip-item {
.notice-item-tag {
display: none;
}
.notice-item-title {
font-size: 11px;
}
}
.more-text {
font-size: 12px;
color: #64748b;
}
}
}

View File

@@ -27,7 +27,40 @@
</div>
<div class="big-card-info">
<div class="big-title">{{ featuredGoods.goodsName }}</div>
<div class="big-reason">99.8% 高好评 · 猜你对这款高人气科技数码感兴趣</div>
<div class="big-reason">官方甄选 · 猜你对这款高人气商品感兴趣</div>
<!-- 核心特色标签 -->
<div class="big-tags-row">
<span class="feat-tag highlight">🔥 镇店爆品</span>
<span class="feat-tag">🛡️ 官方正品</span>
<span class="feat-tag">⚡ 极速发货</span>
<span class="feat-tag">🏷️ 售后无忧</span>
</div>
<!-- 权威热度指标 -->
<div class="big-stats-bar">
<div class="stat-cell">
<span class="stat-val">100%</span>
<span class="stat-lbl">品质正品</span>
</div>
<div class="stat-line"></div>
<div class="stat-cell">
<span class="stat-val">极速达</span>
<span class="stat-lbl">顺丰速运</span>
</div>
<div class="stat-line"></div>
<div class="stat-cell">
<span class="stat-val">TOP 1</span>
<span class="stat-lbl">品类热榜</span>
</div>
</div>
<!-- 服务保障权益 -->
<div class="big-guarantees">
<div class="guar-item"><span class="guar-icon">✓</span> 7天无理由退换货</div>
<div class="guar-item"><span class="guar-icon">✓</span> 赠运费险 · 破损包赔</div>
</div>
<div class="big-price-row">
<div class="big-price">
<span class="sym">¥</span>
@@ -86,8 +119,7 @@
<div class="card-body">
<div class="goods-title">{{ item.goodsName }}</div>
<div class="recommend-meta">
<span class="sales-num">已售 {{ 200 + idx * 70 }}+</span>
<span class="rate-num">98%好评</span>
<span class="reason-tag">甄选推荐</span>
</div>
<div class="price-row">
<div class="price-box">
@@ -312,10 +344,92 @@ const customStyle = computed(() => {
background: #fff7ed;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 12px;
margin-bottom: 10px;
line-height: 1.3;
}
.big-tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
.feat-tag {
font-size: 11px;
color: #475569;
background: #f1f5f9;
padding: 2px 7px;
border-radius: 4px;
font-weight: 500;
&.highlight {
color: #dc2626;
background: #fee2e2;
font-weight: 600;
}
}
}
.big-stats-bar {
display: flex;
align-items: center;
justify-content: space-around;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 6px;
margin-bottom: 10px;
.stat-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-val {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.stat-lbl {
font-size: 10px;
color: #64748b;
}
}
.stat-line {
width: 1px;
height: 20px;
background: #cbd5e1;
}
}
.big-guarantees {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 10px;
background: #fbfbfb;
border-left: 3px solid #ef4444;
border-radius: 0 4px 4px 0;
margin-bottom: 12px;
.guar-item {
font-size: 11px;
color: #475569;
display: flex;
align-items: center;
gap: 4px;
.guar-icon {
color: #16a34a;
font-weight: 700;
font-size: 11px;
}
}
}
.big-price-row {
margin-top: auto;
display: flex;
@@ -438,10 +552,10 @@ const customStyle = computed(() => {
flex: 1;
.goods-title {
font-size: 12px;
font-size: 14px;
color: #1e293b;
line-height: 1.4;
height: 34px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;

View File

@@ -7,6 +7,12 @@
]"
>
<div class="title-content">
<img
v-if="options.titleImg || options.icon"
:src="options.titleImg || options.icon"
class="title-custom-icon"
alt="icon"
/>
<span class="main-t">{{ options.mainTitle || '楼层主标题' }}</span>
<span class="sub-t" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
@@ -53,11 +59,18 @@ const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
.title-content {
display: flex;
align-items: baseline;
align-items: center;
flex-wrap: wrap;
gap: 4px 10px;
gap: 8px 10px;
min-width: 0;
.title-custom-icon {
width: 24px;
height: 24px;
object-fit: contain;
flex-shrink: 0;
}
.main-t {
font-size: 22px;
font-weight: 800;
@@ -84,6 +97,10 @@ const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
&.is-compact {
.title-content {
.title-custom-icon {
width: 20px;
height: 20px;
}
.main-t {
font-size: 18px;
}
@@ -99,37 +116,31 @@ const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
&.is-mini {
flex-direction: column;
align-items: flex-start;
gap: 4px;
gap: 6px;
&.align-center {
align-items: center;
.more-link {
position: static;
margin-top: 2px;
margin-top: 4px;
}
}
.title-content {
flex-direction: column;
align-items: flex-start;
gap: 2px;
.title-custom-icon {
width: 16px;
height: 16px;
}
.main-t {
font-size: 16px;
font-size: 15px;
}
.sub-t {
font-size: 12px;
font-size: 11px;
}
}
&.align-center .title-content {
align-items: center;
text-align: center;
}
.more-link {
font-size: 12px;
margin-top: 2px;
font-size: 11px;
}
}
}

View File

@@ -72,13 +72,15 @@ const customStyle = computed(() => {
}
.bar-inner {
width: 100%;
width: 1200px;
max-width: 100%;
padding: 0 24px;
margin: 0 auto;
padding: 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
.bar-left {
display: flex;
@@ -98,7 +100,7 @@ const customStyle = computed(() => {
white-space: nowrap;
.auth-btn {
color: #ff6a00;
color: #409EFF;
cursor: pointer;
&:hover {
text-decoration: underline;
@@ -120,7 +122,7 @@ const customStyle = computed(() => {
white-space: nowrap;
&:hover {
color: #ff6a00;
color: #409EFF;
}
}
}

View File

@@ -30,65 +30,29 @@
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<div class="metric-card">
<div class="metric-card" v-if="options.showCart !== false">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</div>
<div class="metric-card">
<div class="metric-card" v-if="options.showCoupons !== false">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</div>
<div class="metric-card">
<div class="metric-card" v-if="options.showOrders !== false">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</div>
</div>
<!-- 4. 分隔线 -->
<div class="section-divider"></div>
<!-- 5. 热点快报(万米魔方标准组件底部模块) -->
<div class="news-section">
<div class="news-header">
<div class="news-title-wrap">
<span class="news-title">热点快报</span>
<span class="hot-badge">HOT</span>
</div>
<span class="news-more">查看全部 &gt;</span>
</div>
<div class="news-list">
<div class="news-item" title="精选优质好物 官方正品保障">
<span class="dot">·</span>
<span class="news-text">精选优质好物 官方正品保障</span>
</div>
<div class="news-item" title="双11狂欢 跨店每满300减50">
<span class="dot">·</span>
<span class="news-text">双11狂欢 跨店每满300减50</span>
</div>
<div class="news-item" title="新人专享好礼 注册立领大礼包">
<span class="dot">·</span>
<span class="news-text">新人专享好礼 注册立领大礼包</span>
</div>
</div>
</div>
<!-- 6. 快捷便民服务入口(底部图标排) -->
<!-- 4. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<div class="service-item">
<el-icon class="service-icon"><Star /></el-icon>
<span class="service-name">收藏</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Clock /></el-icon>
<span class="service-name">足迹</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Present /></el-icon>
<span class="service-name">红包</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Service /></el-icon>
<span class="service-name">客服</span>
<div
v-for="(srv, sIdx) in displayServices"
:key="sIdx"
class="service-item"
>
<el-icon class="service-icon"><component :is="getServiceIcon(srv.icon)" /></el-icon>
<span class="service-name">{{ srv.name }}</span>
</div>
</div>
</div>
@@ -106,16 +70,48 @@ const props = defineProps({
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
];
const iconMap = {
Star,
Clock,
Present,
Service
};
function getServiceIcon(iconName) {
return iconMap[iconName] || Service;
}
const displayServices = computed(() => {
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
return props.options.services;
}
return defaultServices;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
return {
backgroundColor: s.backgroundColor || '#ffffff',
borderRadius: Array.isArray(s.radius)
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
: '8px',
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
});
</script>
@@ -124,18 +120,19 @@ const customStyle = computed(() => {
.tpl-user-benefits {
width: 100%;
height: 100%;
min-height: 420px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
padding: 14px 12px 10px;
padding: 24px 16px 20px;
gap: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
.user-main-info {
display: flex;
@@ -143,8 +140,8 @@ const customStyle = computed(() => {
gap: 8px;
.avatar-box {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
@@ -175,7 +172,7 @@ const customStyle = computed(() => {
.badge-tag {
align-self: flex-start;
padding: 1px 6px;
background: #fff2e8;
background: #ecf5ff;
color: #409EFF;
font-size: 10px;
font-weight: 700;
@@ -208,38 +205,28 @@ const customStyle = computed(() => {
.action-btn-row {
display: flex;
gap: 8px;
margin-top: 10px;
margin-top: 8px;
.action-btn {
flex: 1;
height: 28px;
line-height: 28px;
height: 32px;
line-height: 32px;
font-size: 12px;
font-weight: 500;
text-align: center;
border-radius: 14px;
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s ease;
&.login-btn {
background: #409EFF;
color: #ffffff;
box-shadow: 0 2px 6px rgba(255, 96, 0, 0.25);
&:hover {
background: #66b1ff;
}
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
}
&.register-btn {
background: #ffffff;
color: #409EFF;
border: 1px solid #409EFF;
&:hover {
background: #fff7ed;
}
}
}
}
@@ -248,21 +235,21 @@ const customStyle = computed(() => {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 0;
margin-top: 10px;
padding: 12px 0;
margin-top: 6px;
background: #f8fafc;
border-radius: 6px;
.metric-card {
text-align: center;
flex: 1;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
.metric-val {
font-size: 15px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
color: #409EFF;
line-height: 1.2;
}
.metric-label {
@@ -270,128 +257,30 @@ const customStyle = computed(() => {
color: #64748b;
margin-top: 2px;
}
&:hover {
.metric-val, .metric-label {
color: #409EFF;
}
}
}
}
.section-divider {
height: 1px;
background: #f1f5f9;
margin: 8px 0 6px;
}
.news-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2px;
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.news-title-wrap {
display: flex;
align-items: center;
gap: 4px;
.news-title {
font-size: 12px;
font-weight: 700;
color: #1e293b;
}
.hot-badge {
background: #ef4444;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 0 3px;
border-radius: 2px;
line-height: 1.2;
}
}
.news-more {
font-size: 11px;
color: #94a3b8;
cursor: pointer;
&:hover {
color: #409EFF;
}
}
}
.news-list {
display: flex;
flex-direction: column;
gap: 5px;
.news-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #475569;
cursor: pointer;
line-height: 1.3;
transition: color 0.15s;
.dot {
color: #409EFF;
font-weight: 700;
}
.news-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
color: #409EFF;
}
}
}
}
.quick-service-row {
display: flex;
align-items: center;
justify-content: space-around;
padding-top: 8px;
align-items: center;
padding-top: 14px;
border-top: 1px dashed #f1f5f9;
margin-top: 4px;
margin-top: 6px;
.service-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
color: #64748b;
transition: color 0.15s;
.service-icon {
font-size: 14px;
font-size: 20px;
}
.service-name {
font-size: 10px;
line-height: 1;
}
&:hover {
color: #409EFF;
font-size: 11px;
}
}
}

View File

@@ -1,8 +1,28 @@
<template>
<div class="tpl-video" :style="customStyle">
<div class="video-preview-box">
<img :src="options.coverUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png'" alt="cover" class="cover-img" />
<div class="play-btn">▶</div>
<video
v-if="options.videoUrl"
:src="options.videoUrl"
:poster="options.coverUrl"
:controls="options.controls !== false"
muted
class="video-el"
/>
<template v-else>
<img
:src="options.coverUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png'"
alt="cover"
class="cover-img"
/>
<div class="play-btn">▶</div>
</template>
<!-- 链接徽标(装修态提示) -->
<div class="video-link-badge" v-if="options.url" :title="options.url">
<span class="badge-icon">🔗</span>
<span class="badge-text">{{ options.url }}</span>
</div>
</div>
</div>
</template>
@@ -24,7 +44,7 @@ const props = defineProps({
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 360}px`,
height: `${props.options?.height || 450}px`,
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
@@ -48,30 +68,69 @@ const customStyle = computed(() => {
align-items: center;
justify-content: center;
.video-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.play-btn {
position: absolute;
width: 50px;
height: 50px;
width: 54px;
height: 54px;
border-radius: 50%;
background: rgba(225, 29, 72, 0.9);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 22px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.2s ease;
cursor: pointer;
&:hover {
transform: scale(1.08);
}
}
.video-link-badge {
position: absolute;
top: 12px;
right: 12px;
max-width: 60%;
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
backdrop-filter: blur(4px);
font-size: 11px;
color: #e2e8f0;
overflow: hidden;
pointer-events: none;
.badge-icon {
font-size: 11px;
flex-shrink: 0;
}
.badge-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
</style>