feat(seller): align PC decoration editor and remove legacy components

This commit is contained in:
pikachu1995@126.com
2026-11-03 11:11:57 +08:00
parent fb99824a3a
commit 5300988643
64 changed files with 19228 additions and 7511 deletions

View File

@@ -206,7 +206,11 @@ function getIndexData() {
}
async function parsePageData(pageData) {
const dataJson = JSON.parse(pageData);
const dataJson = typeof pageData === 'string' ? JSON.parse(pageData) : pageData;
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelForm.value = dataJson;
return;
}
for (let i = 0; i < dataJson.list.length; i++) {
const type = dataJson.list[i].type;
if (type === "carousel2") {

View File

@@ -124,8 +124,7 @@ function open(type, mutiple) {
}
// 外部通过 ref 访问的成员(grep seller/src 全量确认):
// - open:所有调用方打开弹窗(goods/link)
// - singleGoods:shop/modelList/forYour.vue、mixModel.vue 直接调用
// - clearGoodsSelected:shop/wap/decorate.vue、modelList/goodsAndType.vue 调用
// - clearGoodsSelected:shop/wap/decorate.vue 调用
// - goodsData:促销/采购/装修多处回写已选商品
// - goodsFlag:shop/wap/decorate.vue 直接写入
// - flag(可写 computed):shop/wap/decorate.vue 写入触发弹层开关

View File

@@ -1,879 +0,0 @@
import nav1 from '@/assets/nav/1.jpg';
import nav2 from '@/assets/nav/2.jpg';
import nav3 from '@/assets/nav/3.jpg';
import nav4 from '@/assets/nav/4.jpg';
import nav5 from '@/assets/nav/5.jpg';
import navDecorate from '@/assets/nav/decorate.png';
import navDecorate1 from '@/assets/nav/decorate1.png';
import navDecorate2 from '@/assets/nav/decorate2.jpeg';
import navDecorate3 from '@/assets/nav/decorate3.jpeg';
import navDecorate4 from '@/assets/nav/decorate4.jpeg';
import navDecorate5 from '@/assets/nav/decorate5.jpeg';
import navDecorate6 from '@/assets/nav/decorate6.jpeg';
import navDecorate7 from '@/assets/nav/decorate7.jpeg';
import navDecorate8 from '@/assets/nav/decorate8.png';
import navDecorate9 from '@/assets/nav/decorate9.png';
import navDecorate10 from '@/assets/nav/decorate10.jpeg';
import navDecorate11 from '@/assets/nav/decorate11.jpeg';
export const modelData = [
{
type: 'hotAdvert',
name: '热门广告',
icon: 'md-image',
showName: '',
options: {
list: [{
img: navDecorate1,
url: '',
size: '1200*自定义'
},
{
img: nav1,
url: '',
size: '230*190'
},
{
img: nav1,
url: '',
size: '230*190'
},
{
img: nav1,
url: '',
size: '230*190'
},
{
img: nav1,
url: '',
size: '230*190'
},
{
img: nav1,
url: '',
size: '230*190'
}
],
},
},
{
type: 'seckill',
name: '促销活动',
icon: 'md-image',
showName: '',
options: {
list: [{
time: 6,
goodsList: [{
img: nav1,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafads123213a',
url: ''
},
{
img: nav2,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav3,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav4,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav5,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav1,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav2,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav3,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav4,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
{
img: nav5,
price: 20,
originalPrice: 30,
name: '阿迪达斯三叶草asdasdafadsa',
url: ''
},
]
},
{
time: 8,
goodsList: [{
img: nav1,
url: ''
},
{
img: nav2,
url: ''
},
{
img: nav3,
url: ''
},
{
img: nav4,
url: ''
},
{
img: nav5,
url: ''
},
]
},
{
time: 10,
goodsList: [{
img: nav1,
url: ''
},
{
img: nav2,
url: ''
},
{
img: nav3,
url: ''
},
{
img: nav4,
url: ''
},
{
img: nav5,
url: ''
},
]
},
{
time: 12,
goodsList: [{
img: nav1,
url: ''
},
{
img: nav2,
url: ''
},
{
img: nav3,
url: ''
},
{
img: nav4,
url: ''
},
{
img: nav5,
url: ''
},
]
},
{
time: 14,
goodsList: []
},
{
time: 16,
goodsList: []
},
{
time: 18,
goodsList: []
}
]
},
},
{
type: 'discountAdvert',
name: '折扣广告',
icon: 'md-image',
options: {
bgImg: {
img: navDecorate,
url: '',
size: "1300*585"
},
classification: [{
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, {
img: navDecorate2,
url: '',
size: '190*210'
}, ],
brandList: [{
img: navDecorate11,
url: '',
size: '240*105'
}, {
img: navDecorate11,
url: '',
size: '240*105'
}, {
img: navDecorate11,
url: '',
size: '240*105'
}, {
img: navDecorate11,
url: '',
size: '240*105'
}, ]
},
},
{
type: 'recommend',
name: '好货推荐',
icon: 'md-image',
options: {
contentLeft: {
title: '发现好货',
secondTitle: '更多好货',
bgColor: '#449dae',
url: '',
list: [{
img: navDecorate3,
name: '阿迪达斯三叶草',
describe: '也许是每一款经典系列都应该有一个独特的故事吧',
url: '',
size: '160*160'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
{
img: navDecorate4,
name: '360行车记录',
describe: '夜行 监控 电子狗 蓝牙',
url: '',
size: '80*80'
},
]
},
contentRight: {
title: '特色推荐',
secondTitle: '更多特色推荐',
bgColor: '#a25684',
url: '',
list: [{
img: navDecorate5,
name: '好心情喝出来',
describe: '遇见懂你的饮品',
url: '',
size: '100*100'
},
{
img: navDecorate5,
name: '好心情喝出来',
describe: '遇见懂你的饮品',
url: '',
size: '100*100'
},
{
img: navDecorate5,
name: '好心情喝出来',
describe: '遇见懂你的饮品',
url: '',
size: '100*100'
},
{
img: navDecorate5,
name: '好心情喝出来',
describe: '遇见懂你的饮品',
url: '',
size: '100*100'
},
]
}
},
},
{
type: 'newGoodsSort',
name: '新品排行',
icon: 'md-image',
options: {
left: {
title: '特卖',
secondTitle: "更多特卖",
bgColor: '#c43d7e',
url: '',
list: [{
name: '新年心愿单',
describe: '满269减50,满999减100',
img: navDecorate6,
url: '',
size: "160*160"
},
{
name: 'Ms.Maggie 冬季时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: 'Ms.Maggie 冬季时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: 'Ms.Maggie 冬季时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
],
},
middle: {
title: '新品',
secondTitle: "更多新品",
bgColor: '#e66a07',
url: '',
list: [{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
{
name: '阿迪达斯 领跑时尚',
describe: '满269减50',
img: navDecorate6,
url: '',
size: "90*90"
},
]
},
right: {
title: '排行榜',
secondTitle: "精品风向标",
bgColor: '#b62323',
url: '',
list: [{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
{
name: '小米红米3s手机壳保护套红米3高配版指纹男女款潮版磨砂硬壳防摔 收藏截图 送大礼包',
price: 14.9,
img: navDecorate7,
url: ''
},
]
}
},
},
{
type: 'firstAdvert',
name: '首页广告',
icon: 'md-image',
options: {
list: [{
name: '生鲜',
describe: "年货带回家 满199减60",
img: navDecorate8,
url: '',
fromColor: '#e89621',
toColor: "#f5c568",
size: '170*170'
},
{
name: '众筹',
describe: "年货带回家",
img: navDecorate9,
url: '',
fromColor: "#325bb4",
toColor: '#4c9afe',
size: '170*170'
},
{
name: '生鲜',
describe: "年货带回家 满199减60",
img: navDecorate8,
url: '',
fromColor: "#1c9daf",
toColor: '#40cda7',
size: '170*170'
},
{
name: '众筹',
describe: "备孕有孕检测仪",
img: navDecorate9,
url: '',
fromColor: "#d13837",
toColor: '#df6d4f',
size: '170*170'
},
{
name: '生鲜',
describe: "年货带回家 满199减60",
img: navDecorate8,
url: '',
fromColor: "#ca4283",
toColor: '#eb75cf',
size: '170*170'
},
{
name: '众筹',
describe: "备孕有孕检测仪",
img: navDecorate9,
url: '',
fromColor: "#5d40c1",
toColor: '#8c5fdb',
size: '170*170'
},
],
},
},
{
type: 'bannerAdvert',
name: '横幅广告',
icon: 'md-image',
options: {
img: '',
url: '',
size: '1200*自定义'
},
},
{
type: 'notEnough',
name: '还没逛够',
icon: 'md-image',
options: {
list: [
[{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
],
[{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
],
[{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
],
[{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
{
img: navDecorate10,
name: 'Apple/苹果 13 英寸:MacBook Pro Multi-Touch Bar 和 Touch ID 2.9GHz 处理器 512GB 存储容量',
price: 6666,
url: ''
},
],
],
navList: [{
title: '精选',
desc: '猜你喜欢'
},
{
title: '智能先锋',
desc: '大电器城'
},
{
title: '居家优品',
desc: '品质生活'
},
{
title: '超市百货',
desc: '百货生鲜'
},
]
},
},
]

View File

@@ -1,415 +0,0 @@
<template>
<div class="model-form">
<div class="model-content">
<div
class="top-fixed-advert"
:style="{ backgroundColor: topAdvert.bgColor }"
>
<img :src="topAdvert.img" width="1200" height="80" alt="" />
<div class="setup-box">
<el-button size="small" @click.stop="handleModel('topAdvert')">编辑</el-button>
</div>
</div>
<div class="header-con">
<div></div>
<ul class="detail">
<li>立即注册</li>
<li>请登录</li>
<li>我的订单</li>
<li>我的足迹</li>
<li>
<el-icon :size="18"><ShoppingCart /></el-icon>
购物车
</li>
<li>店铺入驻</li>
</ul>
</div>
<div class="search-con">
<img :src="logoImg" class="logo" alt="" />
<div class="search">
<el-input size="large" placeholder="输入你想查找的商品">
<template #append>
<el-button>搜索</el-button>
</template>
</el-input>
</div>
</div>
<div class="nav-con" v-if="$route.query.pageType !== 'SPECIAL'">
<div class="all-categories">全部商品分类</div>
<ul class="nav-item">
<li v-for="(item, index) in navList.list" :key="index">
<a href="#">{{ item.name }}</a>
</li>
</ul>
<div class="setup-box">
<el-button size="small" @click.stop="handleModel('quickNav')">编辑</el-button>
</div>
</div>
<div>
<draggable
class="model-form-list"
v-model="data.list"
:item-key="getModelItemKey"
group="model"
ghost-class="ghost"
@end="handleMoveEnd"
@add="handleModelAdd"
>
<template #item="{ element, index }">
<model-form-item
v-if="element && element.key"
:element="element"
:index="index"
:data="data"
/>
</template>
</draggable>
</div>
</div>
<el-dialog
v-model="showModal"
title="顶部广告"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div>
<img class="show-image" width="600" height="40" :src="topAdvert.img" alt />
</div>
<div class="tips">建议尺寸:<span>{{ topAdvert.size }}</span></div>
<div>
图片链接:<el-input
class="outsideUrl"
v-model="topAdvert.url"
:disabled="!!topAdvert.type && topAdvert.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink">选择链接</el-button>
</div>
<div>
选择图片:<el-button size="small" type="primary" @click="handleSelectImg">选择图片</el-button>
</div>
<div>选择背景色:<el-color-picker v-model="topAdvert.bgColor" /></div>
</div>
</el-dialog>
<el-dialog
v-model="showModalNav"
title="快捷导航"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-tab-bar">
<el-button type="primary" size="small" @click="handleAddNav">添加分类</el-button>
<table cellspacing="0">
<thead>
<tr>
<th width="250">分类名称</th>
<th width="250">链接地址</th>
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in navList.list" :key="index">
<td><el-input v-model="item.name" /></td>
<td>
<el-input
v-model="item.url"
:disabled="!item.title || item.title !== '外部链接'"
/>
</td>
<td>
<el-button type="primary" size="small" @click="handleSelectLink(item, index)">选择链接</el-button>
<el-button type="danger" size="small" @click="handleDelNav(index)">删除</el-button>
</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink"></liliDialog>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted } from "vue";
import { ShoppingCart } from "@element-plus/icons-vue";
import logoImg from "@/assets/logo.png";
import Draggable from "vuedraggable";
import ModelFormItem from "./modelFormItem.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "modelForm" });
const props = defineProps(["data"]);
const picModelFlag = ref(false);
const showModal = ref(false);
const showModalNav = ref(false);
const selectedNav = ref(null);
const topAdvert = ref({
type: "topAdvert",
img: "",
url: "",
bgColor: "#de000d",
size: "1200*80",
});
const currentIndex = ref(0);
const navList = ref({
type: "navBar",
list: [
{ name: "秒杀", url: "", title: "" },
{ name: "闪购", url: "", title: "" },
{ name: "优惠券", url: "", title: "" },
{ name: "拍卖", url: "", title: "" },
{ name: "服装城", url: "", title: "" },
],
});
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
// 父组件 renovation.vue 通过 $refs.modelForm 读取并整体赋值 navList / topAdvert
defineExpose({ navList, topAdvert });
onMounted(() => {
document.body.ondrop = function (event) {
let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
if (isFirefox) {
event.preventDefault();
event.stopPropagation();
}
};
});
function getModelItemKey(element) {
return element.model || element.key || element.type;
}
function handleSelectLink(item, index) {
if (item) selectedNav.value = item;
liliDialogRef.value.open("link");
currentIndex.value = index;
}
function selectedLink(val) {
if (showModalNav.value) {
selectedNav.value.url = formatLinkType(val);
selectedNav.value.type = val.___type === "other" && val.url === "" ? "link" : "other";
} else {
topAdvert.value.url = formatLinkType(val);
topAdvert.value.type = val.___type === "other" && val.url === "" ? "link" : "other";
}
navList.value.list[currentIndex.value].title = val.title;
}
function handleDelNav(index) {
navList.value.list.splice(index, 1);
}
function handleAddNav() {
navList.value.list.push({ name: "", url: "", title: "" });
nextTick(() => {
selectedNav.value = navList.value.list[navList.value.list.length - 1];
});
}
function handleMoveEnd({ newIndex, oldIndex }) {}
function handleModel(type) {
if (type == "topAdvert") {
showModal.value = true;
} else {
showModalNav.value = true;
}
}
function handleSelectImg() {
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
function callbackSelected(item) {
picModelFlag.value = false;
topAdvert.value.img = item.url;
}
function handleModelAdd(evt) {
const newIndex = evt.newIndex;
const current = props.data.list[newIndex];
if (!current || current.key) {
return;
}
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
props.data.list[newIndex] = {
...JSON.parse(JSON.stringify(current)),
options: {
...current.options,
},
key,
model: current.type + "_" + key,
};
}
</script>
<style lang="scss" scoped>
@import "./modelList/setup-box.scss";
.model-form {
width: 1500px;
max-width: 100%;
}
.model-content {
width: 1200px;
margin: 0 auto;
background: #fff;
min-height: 1200px;
overflow-x: visible;
}
.model-form-list {
min-height: 500px;
box-sizing: border-box;
overflow: visible;
}
.top-fixed-advert {
display: flex;
width: 1500px;
margin-left: -150px;
background: $theme_color;
justify-content: center;
}
.header-con {
display: flex;
justify-content: space-between;
height: 35px;
padding: 0 15px;
line-height: 35px;
color: #999;
font-weight: bold;
div,
li {
&:hover {
color: $theme_color;
cursor: pointer;
}
}
.detail {
display: flex;
> li {
margin-left: 10px;
&::after {
content: "|";
padding-left: 10px;
}
&:last-child::after {
content: "";
padding-left: 0;
}
&:hover::after {
color: #999;
}
}
}
}
.search-con {
padding-top: 15px;
margin: 0px auto;
margin-bottom: 10px;
width: 1200px;
position: relative;
.logo {
position: absolute;
top: 10px;
left: 10px;
width: 150px;
height: 50px;
}
.search {
width: 460px;
margin: 0 auto;
:deep(.el-input--large .el-input__wrapper) {
border: 2px solid $theme_color;
font-size: 12px;
height: 34px;
box-shadow: none;
}
:deep(.el-input-group__append) {
border: 1px solid $theme_color;
border-left: none;
height: 30px;
background-color: $theme_color;
color: #ffffff;
.el-button {
font-size: 14px;
font-weight: 600;
line-height: 1;
color: #fff;
}
}
}
}
.nav-con {
width: 1200px;
height: 40px;
background: #eee;
display: flex;
.all-categories {
width: 200px;
line-height: 40px;
color: #fff;
background-color: $theme_color;
text-align: center;
font-size: 16px;
}
.nav-item {
width: 1000px;
height: 40px;
line-height: 40px;
overflow: hidden;
list-style: none;
background-color: #eee;
display: flex;
li {
font-size: 16px;
font-weight: bold;
margin-left: 20px;
a {
color: rgb(89, 88, 88);
font-size: 15px;
&:hover {
color: $theme_color;
}
}
}
}
}
.top-fixed-advert,
.nav-con {
position: relative;
&:hover {
.setup-box {
display: block;
}
}
}
.modal-top-advert {
align-items: start;
padding: 0 30px;
}
</style>

View File

@@ -1,668 +0,0 @@
<template>
<div class="model-item" v-if="element && element.key">
<!-- 轮播图模块,包括个人信息,快捷导航模块 -->
<template v-if="element.type == 'carousel'">
<model-carousel :data="element"></model-carousel>
</template>
<!-- 轮播图模块,100%宽度,无个人信息栏 -->
<template v-if="element.type == 'carousel1'">
<model-carousel1 class="mb_20" :data="element"></model-carousel1>
</template>
<!-- 轮播图模块,包括个人信息,两个轮播模块 -->
<template v-if="element.type == 'carousel2'">
<model-carousel2 class="mb_20" :data="element"></model-carousel2>
</template>
<!-- 热门广告 -->
<template v-if="element.type == 'hotAdvert'">
<div class="hot-advert">
<div class="hot-advert-banner setup-content">
<img
:src="element.options.list[0].img"
@click="$router.push(element.options.list[0].url)"
alt=""
/>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(element.options.list[0])"
>编辑</el-button
>
</div>
</div>
</div>
<ul class="advert-list">
<template v-for="(item, index) in element.options.list">
<li
v-if="index !== 0"
@click="$router.push(item.url)"
class="setup-content"
:key="index"
>
<img :src="item.img" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</li>
</template>
</ul>
</div>
</template>
<!-- 限时秒杀 待完善 -->
<template v-if="element.type == 'seckill'">
<seckill :data="element"></seckill>
</template>
<!-- 限时秒杀 待完善 -->
<template v-if="element.type == 'seckill-only-album'">
<seckill-only-album :data="element"></seckill-only-album>
</template>
<!-- 折扣广告 -->
<template v-if="element.type == 'discountAdvert'">
<div class="discountAdvert-wrap">
<div
class="discountAdvert"
:style="{
'background-image':
'url(' + decorateImg + ')',
}"
>
<div>
<div
v-for="(item, index) in element.options.classification"
:key="index"
class="setup-content"
>
<img :src="item.img" width="190" height="210" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
<div>
<div
v-for="(item, index) in element.options.brandList"
:key="index"
class="setup-content"
>
<img :src="item.img" width="240" height="105" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 好货推荐 -->
<template v-if="element.type == 'recommend'">
<recommend :data="element" class="mb_20 width_1200_auto"></recommend>
</template>
<!-- 新品排行 -->
<template v-if="element.type == 'newGoodsSort'">
<new-goods-sort :data="element" class="mb_20 width_1200_auto"></new-goods-sort>
</template>
<!-- 首页广告 -->
<template v-if="element.type == 'firstAdvert'">
<first-page-advert :data="element" class="mb_20 width_1200_auto"></first-page-advert>
</template>
<!-- 商品模块 -->
<template v-if="element.type == 'goodsType'">
<goodsType :data="element" class="mb_20 width_1200_auto"></goodsType>
</template>
<!-- 商品模块2 -->
<template v-if="element.type == 'onlyGoodsModel'">
<onlyGoodsModel :data="element" class="mb_20 width_1200_auto"></onlyGoodsModel>
</template>
<!-- 混合模块 -->
<template v-if="element.type == 'mixModel'">
<mixModel :data="element" class="mb_20 width_1200_auto"></mixModel>
</template>
<!-- 为你推荐 -->
<template v-if="element.type == 'forYour'">
<forYour :data="element" class="mb_20 width_1200_auto"></forYour>
</template>
<!-- 一行三列 -->
<template v-if="element.type == 'oneRowThreeColumns'">
<oneRowThreeColumns :data="element" class="mb_20 width_1200_auto"></oneRowThreeColumns>
</template>
<!-- 横幅广告 -->
<template v-if="element.type == 'bannerAdvert'">
<div class="horizontal-advert setup-content">
<img
v-if="element.options.img"
class="banner-ad-img"
:src="element.options.img"
alt=""
/>
<div v-else class="default-con">
<p>广告图片</p>
<p>1200*自定义</p>
</div>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(element.options)"
>编辑</el-button
>
</div>
</div>
</div>
</template>
<template v-if="element.type == 'notEnough'">
<not-enough :data="element" class="mb_20 width_1200_auto"></not-enough>
</template>
<div class="del-btn">
<el-button size="small" type="danger" @click="handleModelDelete">删除</el-button>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item modal-form-preview">
<!-- 热门广告两种图片尺寸 -->
<img
class="show-image"
width="600"
height="40"
v-if="selected.size && selected.size.indexOf('1200') >= 0 && selected.img"
:src="selected.img"
alt
/>
<img
class="show-image"
width="230"
height="190"
v-if="selected.size && selected.size.indexOf('230*190') >= 0"
:src="selected.img"
alt
/>
<!-- 折扣广告三种图片尺寸 -->
<img
class="show-image"
width="600"
height="270"
v-if="selected.size && selected.size.indexOf('1300') >= 0"
:src="selected.img"
alt
/>
<img
class="show-image"
width="190"
height="210"
v-if="selected.size && selected.size.indexOf('190*210') >= 0"
:src="selected.img"
alt
/>
<img
class="show-image"
width="240"
height="105"
v-if="selected.size && selected.size.indexOf('240*105') >= 0"
:src="selected.img"
alt
/>
</div>
<div class="tips">建议尺寸:{{ selected.size }}</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">图片链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item">
<span class="modal-label">选择图片:</span>
<el-button size="small" type="primary" @click="handleSelectImg"
>选择图片</el-button
>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal">取消</el-button>
<el-button type="primary" @click="handleConfirmModal">确定</el-button>
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, ref } from "vue";
import ModelCarousel from "./modelList/carousel.vue";
import ModelCarousel1 from "./modelList/carousel1.vue";
import ModelCarousel2 from "./modelList/carousel2.vue";
import FirstPageAdvert from "./modelList/firstPageAdvert.vue";
import NewGoodsSort from "./modelList/newGoodsSort.vue";
import Recommend from "./modelList/recommend.vue";
import NotEnough from "./modelList/notEnough.vue";
import Seckill from "./modelList/seckill.vue";
import goodsType from "./modelList/goodsAndType.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import mixModel from "./modelList/mixModel.vue";
import forYour from "./modelList/forYour.vue";
import onlyGoodsModel from "./modelList/onlyGoodsModel.vue";
import oneRowThreeColumns from "./modelList/oneRowThreeColumns.vue";
import seckillOnlyAlbum from "./modelList/seckill-only-album.vue";
import decorateImg from "@/assets/nav/decorate.png";
import { Modal } from "@/utils/message";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "modelFormItem" });
const props = defineProps(["element", "select", "index", "data"]);
const showModal = ref(false); // modal显隐
const selected = ref({}); // 弹窗编辑草稿
const editTarget = ref(null); // 确定后写回的原数据
const picModelFlag = ref(false); // 图片选择器
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const ossManageRef = ref(null);
function cloneItem(item) {
return JSON.parse(JSON.stringify(item || {}));
}
// 编辑模块
function handleSelectModel(item) {
editTarget.value = item;
selected.value = cloneItem(item);
showModal.value = true;
}
// 删除模块
function handleModelDelete() {
Modal.confirm({
title: "提示",
content: "<p>确定删除当前模块吗?</p>",
onOk: () => {
nextTick(() => {
props.data.list.splice(props.index, 1);
});
},
});
}
function handleSelectLink(item, index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
}
// 确定选择链接
function selectedLink(val) {
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
function handleSelectImg() {
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
// 回显图片
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selected.value.img = val.url;
}
function handleCancelModal() {
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleConfirmModal() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
</script>
<style lang="scss" scoped>
@import "./modelList/setup-box.scss";
.model-item {
position: relative;
margin-bottom: 20px;
width: 100%;
box-sizing: border-box;
overflow: visible;
&:hover {
.del-btn {
display: block;
}
}
}
.model-item :deep(.width_1200_auto) {
width: 100% !important;
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.del-btn {
position: absolute;
left: 100%;
right: auto;
top: 0;
width: auto;
white-space: nowrap;
display: none;
z-index: 100;
&:hover {
display: block;
}
}
/** 横幅广告 */
.horizontal-advert {
width: 100%;
max-width: 1200px;
overflow: hidden;
line-height: 0;
.banner-ad-img {
display: block;
width: 100%;
height: auto;
max-height: 166px;
object-fit: cover;
}
.default-con {
height: 100px;
padding-top: 30px;
text-align: center;
background: #ddd;
line-height: normal;
}
}
/** 热门广告 */
.hot-advert {
width: 100%;
box-sizing: border-box;
overflow: hidden;
.hot-advert-banner {
line-height: 0;
width: 100%;
img {
display: block;
width: 100%;
height: auto;
object-fit: cover;
}
}
}
.advert-list {
width: 100%;
margin: 0;
padding: 10px;
list-style: none;
box-sizing: border-box;
background: $theme_color;
height: 210px;
display: flex;
gap: 10px;
align-items: center;
> li {
flex: 1 1 0;
min-width: 0;
img {
display: block;
width: 100%;
height: 190px;
object-fit: cover;
cursor: pointer;
border-radius: 10px;
transition: all 150ms ease-in-out;
&:hover {
transform: translateY(-3px);
box-shadow: rgba(0, 0, 0, 0.4) 0px 5px 20px 0px;
}
}
}
}
/** 限时秒杀 */
.limit-img {
display: flex;
flex-direction: row;
img {
width: 300px;
height: 100px;
}
}
/** 折扣广告 */
.discountAdvert-wrap {
width: 100%;
position: relative;
overflow: hidden;
}
.discountAdvert {
width: 1300px;
height: 596px;
margin-left: -100px;
box-sizing: border-box;
background-repeat: no-repeat;
background-size: 1300px 596px;
position: relative;
> div {
padding-left: 295px;
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
&:nth-child(1) img {
margin: 10px 10px 0 0;
}
&:nth-child(2) {
margin-top: 10px;
img {
margin: 0 10px 0 0;
}
}
}
}
/** 首页品牌 */
.brand {
.brand-view {
display: flex;
margin-top: 10px;
.brand-view-content {
width: 470px;
margin-left: 10px;
img {
width: 100%;
height: 316px;
}
.brand-view-title {
height: 50px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.brand-view-content:first-child {
width: 240px;
margin-left: 0;
}
}
.brand-list {
margin-top: 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
li {
width: 121px;
height: 112px;
position: relative;
overflow: hidden;
border: 1px solid #f5f5f5;
margin: -1px -1px 0 0;
&:hover {
.brand-mash {
display: flex;
}
}
.brand-img {
text-align: center;
margin-top: 30px;
img {
width: 100px;
height: auto;
}
}
.brand-mash {
display: none;
position: absolute;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.5);
width: inherit;
height: inherit;
font-size: 12px;
font-weight: bold;
.el-icon {
position: absolute;
right: 10px;
top: 10px;
font-size: 15px;
}
align-items: center;
justify-content: center;
flex-direction: column;
color: #fff;
cursor: pointer;
div:last-child {
background-color: $theme_color;
border-radius: 9px;
padding: 0 10px;
margin-top: 5px;
}
}
}
.refresh {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
.el-icon {
font-size: 18px;
transition: all 0.3s ease-out;
}
&:hover {
background-color: $theme_color;
color: #fff;
.el-icon {
transform: rotateZ(360deg);
}
}
}
}
}
/** 装修模态框 内部样式start */
.modal-top-advert {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
.modal-form-item {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 16px;
.modal-label {
flex-shrink: 0;
width: 100px;
text-align: right;
padding-right: 12px;
}
.el-input {
flex: 1;
}
}
.modal-form-preview {
justify-content: center;
margin-bottom: 8px;
}
.modal-form-link {
align-items: flex-start;
.modal-label {
padding-top: 8px;
}
.modal-link-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
.outsideUrl {
flex: 1;
min-width: 0;
}
}
}
.tips {
padding-left: 112px;
margin-bottom: 16px;
color: #999;
font-size: 12px;
}
}
</style>

View File

@@ -1,306 +0,0 @@
<template>
<div class="model-carousel">
<div class="nav-body clearfix">
<!-- 侧边导航 -->
<div class="nav-side">分类占位区</div>
<div class="nav-content setup-content">
<!-- 轮播图 -->
<el-carousel autoplay height="340px">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img alt="轮播图" :src="item.img" width="790" height="340" />
</div>
</el-carousel-item>
</el-carousel>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel">编辑</el-button>
</div>
</div>
</div>
<div class="nav-right">
<div class="person-msg">
<img :src="userInfo.face" v-if="userInfo.face" alt />
<el-avatar class="mb_10" v-else :size="80" />
<div>
Hi,{{ userInfo.nickName || "欢迎来到管理后台" }}
</div>
<div v-if="userInfo.id">
<el-button type="danger" circle>会员中心</el-button>
</div>
<div v-else>
<el-button type="danger" circle>请登录</el-button>
</div>
</div>
<div class="shop-msg">
<div>
<span>常见问题</span>
<ul class="article-list">
<li
class="ellipsis"
:alt="article.title"
v-for="(article, index) in articleList"
:key="index"
>
{{ article.title }}
</li>
</ul>
</div>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="快捷导航"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<el-button type="primary" size="small" @click="handleAdd">添加轮播</el-button>
&nbsp;
<span class="ml_10">图片尺寸:{{ data.size }}</span>
<div style="color: #999" class="fz_12">点击缩略图替换图片</div>
<table cellspacing="0">
<thead>
<tr>
<th width="250">所选图片</th>
<th width="250">链接地址</th>
<!-- <th width="150">排序</th> -->
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data.options.list" :key="index">
<td>
<img
style="cursor: pointer"
:src="item.img"
@click="handleSelectImg(item)"
width="200"
height="100"
alt=""
/>
</td>
<td>
<el-input
class="outsideUrl"
v-model="item.url"
:disabled="!!item.type && item.type !== 'link'"
/>
</td>
<!-- <td><el-input v-model="item.sort"/></td> -->
<td>
<el-button
type="primary"
size="small"
@click="handleSelectImg(item)"
>选择图片</el-button
>&nbsp;
<el-button type="info" size="small" @click="handleSelectLink(item)"
>选择链接</el-button
>&nbsp;
<el-button
type="danger"
plain
size="small"
@click="handleDel(index)"
>删除</el-button
>
</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, nextTick, getCurrentInstance } from "vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "modelCarousel" });
const props = defineProps(["data"]);
const { proxy } = getCurrentInstance();
const showModal = ref(false); // modal显隐
const selected = ref(null); // 已选数据
const picModelFlag = ref(false); // 选择图片modal
const userInfo = reactive({});
const articleList = ref([
{ title: "促销计算规则" },
{ title: "商家申请开店" },
{ title: "商家账号注册" },
{ title: "促销计算规则" },
]);
const liliDialog = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
function handleSelectModel() {
// 编辑模块
showModal.value = true;
}
// 添加轮播图
function handleAdd() {
props.data.options.list.push({ img: "", url: "", type: "" });
proxy.$forceUpdate();
}
function handleSelectLink(item) {
// 选择链接
liliDialog.value.open("link");
selected.value = item;
}
function callbackSelected(item) {
// 选择图片回调
picModelFlag.value = false;
selected.value.img = item.url;
}
function handleDel(index) {
// 删除图片
props.data.options.list.splice(index, 1);
}
function selectedLink(val) {
// 选择链接回调
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
// 选择图片
function handleSelectImg(item) {
selected.value = item;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.model-carousel {
width: 1200px;
height: 340px;
overflow: hidden;
}
.nav-item li {
float: left;
font-size: 16px;
font-weight: bold;
margin-left: 30px;
}
.nav-item a {
text-decoration: none;
color: #555555;
}
.nav-item a:hover {
color: $theme_color;
}
/*大的导航信息,包含导航,幻灯片等*/
.nav-body {
width: 1200px;
height: 340px;
margin: 0px auto;
}
.nav-side {
height: 100%;
width: 200px;
padding: 0px;
color: #fff;
float: left;
background-color: #6e6568;
line-height: 340px;
text-align: center;
}
/*导航内容*/
.nav-content {
width: 800px;
height: 340px;
overflow: hidden;
float: left;
position: relative;
}
.nav-right {
float: left;
width: 200px;
.person-msg {
display: flex;
align-items: center;
flex-direction: column;
margin: 20px auto;
button {
height: 25px !important;
margin-top: 10px;
}
.el-btn-default {
color: $theme_color;
border-color: $theme_color;
}
img {
margin-bottom: 10px;
width: 80px;
height: 80px;
border-radius: 50%;
}
}
.shop-msg {
div {
width: 100%;
margin: 10px 27px;
span {
cursor: pointer;
text-align: center;
font-weight: bold;
margin-left: 5px;
}
span:nth-child(1) {
color: $theme_color;
margin-left: 0;
}
span:nth-child(2) {
font-weight: normal;
}
span:nth-child(3):hover {
color: $theme_color;
}
}
ul {
margin: 0 30px;
li {
cursor: pointer;
margin: 5px 0;
color: #999395;
&:hover {
color: $theme_color;
}
}
}
}
}
</style>

View File

@@ -1,195 +0,0 @@
<template>
<div class="model-carousel1" :style="{ background: bgColor }">
<div class="nav-body clearfix">
<!-- 侧边导航 -->
<div class="nav-side">分类占位区</div>
<div class="nav-content setup-content">
<!-- 轮播图 -->
<el-carousel autoplay @change="autoChange">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img alt="轮播图" :src="item.img" width="1200" height="470" />
</div>
</el-carousel-item>
</el-carousel>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel">编辑</el-button>
</div>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="快捷导航"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<el-button type="primary" size="small" @click="handleAdd">添加轮播</el-button>
&nbsp;
<span class="ml_10">图片尺寸:{{ data.size }}</span>
<span style="color: red" class="fz_12 ml_10"
>点击缩略图替换图片、点击颜色选择器选择背景色</span
>
<table cellspacing="0">
<thead>
<tr>
<th width="250">所选图片</th>
<th width="250">链接地址</th>
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data.options.list" :key="index">
<td>
<img
style="cursor: pointer"
:src="item.img"
@click="handleSelectImg(item)"
width="200"
height="100"
alt=""
/>
</td>
<td>
<el-input
class="outsideUrl"
v-model="item.url"
:disabled="!!item.type && item.type !== 'link'"
/>
</td>
<td>
<el-button type="info" size="small" @click="handleSelectLink(item)"
>选择链接</el-button
>&nbsp;
<el-color-picker size="small" v-model="item.bgColor" />
&nbsp;
<el-button
type="danger"
plain
size="small"
@click="handleDel(index)"
>删除</el-button
>
</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" :max-select="1" ref="ossManageRef" />
</el-dialog>
</div>
</template>
<script setup>
import { getCurrentInstance, nextTick, onMounted, ref } from "vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "modelCarousel" });
const props = defineProps(["data"]);
const { proxy } = getCurrentInstance();
const showModal = ref(false); // modal显隐
const selected = ref(null); // 已选数据
const picModelFlag = ref(false); // 选择图片modal
const bgColor = ref("#fff"); // 轮播背景色
const liliDialog = ref(null);
const ossManageRef = ref(null);
onMounted(() => {
bgColor.value = props.data.options.list[0].bgColor;
});
// 编辑模块
function handleSelectModel() {
showModal.value = true;
}
// 自动切换时改变背景色
function autoChange(oVal, val) {
bgColor.value = props.data.options.list[val].bgColor;
}
// 添加轮播图片和链接
function handleAdd() {
props.data.options.list.push({ img: "", url: "", bgColor: "#fff" });
proxy.$forceUpdate();
}
// 打开选择链接modal
function handleSelectLink(item) {
liliDialog.value.open("link");
selected.value = item;
}
// 选择图片回调
function callbackSelected(item) {
picModelFlag.value = false;
selected.value.img = item.url;
}
// 删除图片
function handleDel(index) {
props.data.options.list.splice(index, 1);
}
// 选择链接回调
function selectedLink(val) {
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
// 打开选择图片modal
function handleSelectImg(item) {
selected.value = item;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.model-carousel1 {
width: 1500px;
height: 470px;
margin-left: -150px;
background: #fff;
}
/*大的导航信息,包含导航,幻灯片等*/
.nav-body {
width: 1200px;
height: 470px;
margin: 0px auto;
}
.nav-side {
height: 100%;
width: 200px;
padding: 0px;
color: #fff;
background-color: rgba(0, 0, 0, 0.5);
line-height: 470px;
text-align: center;
position: absolute;
z-index: 1;
}
/*导航内容*/
.nav-content {
width: 1200px;
height: 470px;
overflow: hidden;
float: left;
position: relative;
}
</style>

View File

@@ -1,404 +0,0 @@
<template>
<div class="model-carousel2">
<div class="nav-body clearfix">
<!-- 侧边导航 -->
<div class="nav-side">分类占位区</div>
<div class="nav-content setup-content">
<!-- 轮播图 -->
<el-carousel autoplay height="340px">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img alt="轮播图" :src="item.img" width="590" height="470" />
</div>
</el-carousel-item>
</el-carousel>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel">编辑</el-button>
</div>
</div>
</div>
<div class="nav-content1 setup-content">
<!-- 轮播图 -->
<el-carousel autoplay :autoplay-speed="5000">
<el-carousel-item
v-for="(item, index) in data.options.listRight"
:key="index"
>
<div class="mb_10">
<img alt="轮播图" :src="item[0].img" width="190" height="150" />
</div>
<div class="mb_10">
<img alt="轮播图" :src="item[1].img" width="190" height="150" />
</div>
<div>
<img alt="轮播图" :src="item[2].img" width="190" height="150" />
</div>
</el-carousel-item>
</el-carousel>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel">编辑</el-button>
</div>
</div>
</div>
<div class="nav-right">
<div class="person-msg">
<img :src="userInfo.face" v-if="userInfo.face" alt />
<el-avatar class="mb_10" v-else :size="80" />
<div>
Hi,{{ userInfo.nickName || "欢迎来到管理后台" }}
</div>
<div v-if="userInfo.id">
<el-button type="danger" circle>会员中心</el-button>
</div>
<div v-else>
<el-button type="danger" circle>请登录</el-button>
</div>
</div>
<div class="shop-msg">
<div>
<span>常见问题</span>
<ul class="article-list">
<li
class="ellipsis"
:alt="article.title"
v-for="(article, index) in articleList"
:key="index"
>
{{ article.title }}
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 左侧轮播装修 -->
<el-dialog
v-model="showModal"
title="快捷导航"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<el-button type="primary" size="small" @click="handleAdd">添加轮播</el-button>
&nbsp;
<span class="ml_10">图片尺寸:{{ data.size }}</span>
<span style="color: red" class="fz_12 ml_10">点击缩略图替换图片</span>
<table cellspacing="0">
<thead>
<tr>
<th width="250">所选图片</th>
<th width="250">链接地址</th>
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data.options.list" :key="index">
<td>
<img
style="cursor: pointer"
:src="item.img"
@click="handleSelectImg(item)"
width="200"
height="100"
alt=""
/>
</td>
<td>
<el-input
class="outsideUrl"
v-model="item.url"
:disabled="!!item.type && item.type !== 'link'"
/>
</td>
<td>
<el-button type="info" size="small" @click="handleSelectLink(item)"
>选择链接</el-button
>&nbsp;
<el-button
type="danger"
plain
size="small"
@click="handleDel(index)"
>删除</el-button
>
</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
<!-- 右侧轮播装修 -->
<el-dialog
v-model="showModal"
title="右侧装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<el-button type="primary" size="small" @click="handleAddGroup"
>添加组</el-button
>
&nbsp;
<span class="ml_10">图片尺寸:{{ data.size }}</span>
<span style="color: red" class="fz_12 ml_10">点击缩略图替换图片</span>
<el-tabs
type="card"
closable
@tab-remove="handleTabRemove"
class="mt_10"
>
<el-tab-pane
:label="'图片组' + (gIndex + 1)"
v-for="(group, gIndex) in data.options.listRight"
:key="gIndex"
>
<table cellspacing="0">
<thead>
<tr>
<th width="250">所选图片</th>
<th width="250">链接地址</th>
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in group" :key="index">
<td>
<img
style="cursor: pointer"
:src="item.img"
@click="handleSelectImg(item)"
width="200"
height="100"
alt=""
/>
</td>
<td>
<el-input
class="outsideUrl"
v-model="item.url"
:disabled="!!item.type && item.type !== 'link'"
/>
</td>
<td>
<el-button
type="info"
size="small"
@click="handleSelectLink(item)"
>选择链接</el-button
>
</td>
</tr>
</tbody>
</table>
</el-tab-pane>
</el-tabs>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" :max-select="1" ref="ossManageRef" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, nextTick, getCurrentInstance } from "vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "modelCarousel" });
const props = defineProps(["data"]);
const { proxy } = getCurrentInstance();
const showModal = ref(false); // modal显隐
const selected = ref(null); // 已选数据
const picModelFlag = ref(false); // 选择图片modal
const userInfo = reactive({});
const articleList = ref([
{ title: "促销计算规则" },
{ title: "商家申请开店" },
{ title: "商家账号注册" },
{ title: "促销计算规则" },
]);
const liliDialog = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
function handleSelectModel() {
// 编辑模块
showModal.value = true;
}
// 添加轮播
function handleAdd() {
props.data.options.list.push({ img: "", url: "" });
proxy.$forceUpdate();
}
// 添加图片组
function handleAddGroup() {
props.data.options.listRight.push([
{ img: "", url: "" },
{ img: "", url: "" },
{ img: "", url: "" },
]);
}
// 删除图片组
function handleTabRemove(index) {
props.data.options.listRight.splice(index, 1);
}
// 打开图片链接
function handleSelectLink(item) {
liliDialog.value.open("link");
selected.value = item;
}
function callbackSelected(item) {
// 选择图片回调
picModelFlag.value = false;
selected.value.img = item.url;
}
// 删除图片
function handleDel(index) {
props.data.options.list.splice(index, 1);
}
function selectedLink(val) {
// 选择链接回调
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
// 打开选择图片modal
function handleSelectImg(item) {
selected.value = item;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.model-carousel2 {
width: 1200px;
height: 470px;
overflow: hidden;
}
.nav-item li {
float: left;
font-size: 16px;
font-weight: bold;
margin-left: 30px;
}
.nav-item a {
text-decoration: none;
color: #555555;
}
.nav-item a:hover {
color: $theme_color;
}
/*大的导航信息,包含导航,幻灯片等*/
.nav-body {
width: 1200px;
height: 470px;
margin: 0px auto;
}
.nav-side {
height: 100%;
width: 200px;
padding: 0px;
color: #fff;
float: left;
background-color: #6e6568;
line-height: 470px;
text-align: center;
}
/*导航内容*/
.nav-content,
.nav-content1 {
width: 590px;
height: 470px;
overflow: hidden;
float: left;
position: relative;
margin-left: 10px;
}
.nav-content1 {
width: 190px;
}
.nav-right {
float: left;
width: 190px;
margin-left: 10px;
.person-msg {
display: flex;
align-items: center;
flex-direction: column;
margin: 20px auto;
button {
height: 25px !important;
margin-top: 10px;
}
.el-btn-default {
color: $theme_color;
border-color: $theme_color;
}
img {
margin-bottom: 10px;
width: 80px;
height: 80px;
border-radius: 50%;
}
}
.shop-msg {
div {
width: 100%;
margin: 10px 27px;
span {
cursor: pointer;
text-align: center;
font-weight: bold;
margin-left: 5px;
}
span:nth-child(1) {
color: $theme_color;
margin-left: 0;
}
span:nth-child(2) {
font-weight: normal;
}
span:nth-child(3):hover {
color: $theme_color;
}
}
ul {
li {
cursor: pointer;
margin: 5px 0;
color: #999395;
width: 150px;
font-size: 12px;
&:hover {
color: $theme_color;
}
}
}
}
}
</style>

View File

@@ -1,324 +0,0 @@
<template>
<div class="first-page-advert">
<div
class="item setup-content"
:style="{
backgroundImage: `linear-gradient(to right, ${item.fromColor}, ${item.toColor})`,
}"
v-for="(item, index) in options.list"
:key="index"
>
<div>
<span class="line top-line"></span>
<p>{{ item.name }}</p>
<span class="line btm-line"></span>
<p>{{ item.describe }}</p>
</div>
<img :src="item.img" width="170" height="170" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item modal-form-preview">
<img
class="show-image"
width="170"
height="170"
:src="selected.img"
alt
/>
</div>
<div class="modal-form-item">
<span class="modal-label">图片主标题:</span>
<el-input v-model="selected.name" />
</div>
<div class="modal-form-item">
<span class="modal-label">图片描述:</span>
<el-input v-model="selected.describe" />
</div>
<div class="tips">建议尺寸:{{ selected.size }}</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">图片链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item">
<span class="modal-label">渐变背景色:</span>
<div class="modal-color-field">
<el-input v-model="selected.fromColor" />
<el-color-picker
v-if="selected.fromColor"
v-model="selected.fromColor"
/>
</div>
</div>
<div class="modal-form-item">
<span class="modal-label">渐变背景色:</span>
<div class="modal-color-field">
<el-input v-model="selected.toColor" />
<el-color-picker
v-if="selected.toColor"
v-model="selected.toColor"
/>
</div>
</div>
<div class="modal-form-item modal-form-exhibition">
<div
:style="{
backgroundImage: `linear-gradient(to right, ${selected.fromColor}, ${selected.toColor})`,
}"
class="exhibition"
></div>
</div>
<div class="modal-form-item">
<span class="modal-label">选择图片:</span>
<el-button size="small" type="primary" @click="handleSelectImg"
>选择图片</el-button
>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal">取消</el-button>
<el-button type="primary" @click="handleConfirmModal">确定</el-button>
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from "vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
const props = defineProps({
data: {
type: Object,
default: null,
},
});
const options = ref(props.data.options); // 当前类型数据
const showModal = ref(false); // modal显隐
const selected = ref({}); // 弹窗编辑草稿
const editTarget = ref(null); // 确定后写回的原数据
const picModelFlag = ref(false); // 图片选择器
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const ossManageRef = ref(null);
function cloneItem(item) {
return JSON.parse(JSON.stringify(item || {}));
}
// 打开装修modal
function handleSelectModel(item, type) {
editTarget.value = item;
selected.value = cloneItem(item);
showModal.value = true;
}
function handleSelectLink(item, index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
}
// 选择链接回调
function selectedLink(val) {
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
function handleSelectImg() {
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
// 选择图片回调
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selected.value.img = val.url;
}
function handleCancelModal() {
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleConfirmModal() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
</script>
<style lang="scss" scoped>
@import "./setup-box.scss";
.first-page-advert {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
.item {
width: 393px;
height: 170px;
margin-top: 10px;
display: flex;
align-items: center;
justify-content: center;
img {
margin-left: 20px;
}
&:nth-of-type(1),
&:nth-of-type(2),
&:nth-of-type(3) {
margin-top: 0;
}
p:nth-of-type(1) {
margin: 3px 0;
font-size: 18px;
color: #fff;
}
p:nth-of-type(2) {
margin-top: 3px;
color: #fff;
}
}
.line {
position: relative;
display: block;
height: 2px;
background: url(../../../assets/festival_icon.png);
z-index: 1;
}
.top-line {
width: 78px;
background-position: -1px -3px;
}
.btm-line {
background-position: 0 -11px;
width: 154px;
}
}
.modal-top-advert {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
.modal-form-item {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 16px;
.modal-label {
flex-shrink: 0;
width: 100px;
text-align: right;
padding-right: 12px;
}
.el-input {
flex: 1;
}
}
.modal-form-preview {
justify-content: center;
margin-bottom: 8px;
}
.modal-form-link {
align-items: flex-start;
.modal-label {
padding-top: 8px;
}
.modal-link-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
.outsideUrl {
flex: 1;
min-width: 0;
}
}
}
.modal-color-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
.el-input {
flex: 1;
}
}
.modal-form-exhibition {
padding-left: 112px;
box-sizing: border-box;
.exhibition {
width: 100%;
height: 50px;
border-radius: 4px;
}
}
.tips {
padding-left: 112px;
margin-bottom: 16px;
color: #999;
font-size: 12px;
}
}
</style>

View File

@@ -1,438 +0,0 @@
<template>
<div class="model" v-if="data">
<div class="for-your">{{ data.options.title }}</div>
<div class="flex card">
<div class="left">
<img :src="data.options.data.image.src" alt="" />
</div>
<div class="right flex">
<!-- 商品区 -->
<div class="goods-list">
<div
class="goods-item"
:key="index"
v-for="(item, index) in data.options.data.list"
>
<div>
<div class="goods-name">{{ item.title }}</div>
<div class="goods-desc">{{ item.desc }}</div>
</div>
<img class="goods-img" :src="item.img" alt="" />
</div>
</div>
<!-- 热卖区 -->
<div class="hot-list">
<div class="hot-title">{{ data.options.data.hot.title }}</div>
<div
class="hot-item flex flex-a-c"
v-for="(item, index) in data.options.data.hot.list"
:key="index"
>
<img :src="item.img" alt="" />
<div class="hot-goods">
<div class="hot-goods-title">{{ item.title }}</div>
<div>
<div class="hot-price">¥{{ $filters.unitPrice(item.price) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel()">编辑 </el-button>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div>
<div class="modal-tab-bar">
<div>
主标题:
<el-input style="width: 200px" v-model="data.options.title" />
</div>
<el-divider content-position="left">左侧商品部分</el-divider>
<div class="flex">
<div>图片 :</div>
<div>
<img class="image" :src="data.options.data.image.src" alt="" />
<div>
<div>推荐尺寸:{{ data.options.data.image.size }}</div>
<div class="link-src">
链接地址:
<el-input
style="width: 200px"
v-model="data.options.data.image.url"
disabled
/>
</div>
<el-button
size="small"
type="primary"
@click="handleSelectImg('image')"
>选择图片
</el-button>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectLink('image')"
>选择链接
</el-button>
</div>
</div>
</div>
<el-divider content-position="left">中间商品部分</el-divider>
<div class="flex column-list">
<div
v-for="(item, index) in data.options.data.list"
:key="index"
class="draggable"
>
<div class="flex">
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="column-goods-list">
<div><el-input v-model="item.title" /></div>
<div><el-input v-model="item.desc" /></div>
<div><el-input disabled v-model="item.url" /></div>
<div>{{ item.size }}</div>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectLink('list', index)"
>选择链接
</el-button>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectImg('list', index)"
>选择图片
</el-button>
</div>
</div>
</div>
</div>
<el-divider content-position="left">右侧商品部分</el-divider>
<div class="flex">
<div>右侧标题 :</div>
<el-input
style="width: 200px"
v-model="data.options.data.hot.title"
/>
</div>
<div class="flex column-list">
<div
v-for="(item, index) in data.options.data.hot.list"
:key="index"
class="draggable"
>
<div class="flex">
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="column-goods-list">
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectGoods('hot', index)"
>选择商品
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from "vue";
import { formatLinkType } from "@/utils/filters";
import ossManage from "@/views/sys/oss-manage/ossManage.vue";
defineOptions({ name: "forYour" });
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
});
const showModal = ref(false);
const picModelFlag = ref(false);
const selectedData = ref(""); //选中的数据
const goodsIndex = ref(0); // 商品索引
const defaultCallbackImageType = ref("");
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
function handleSelectImg(type, index) {
defaultCallbackImageType.value = type;
goodsIndex.value = index;
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
function handleSelectGoods(val, index) {
liliDialogRef.value.open("goods");
liliDialogRef.value.singleGoods();
selectedData.value = val;
goodsIndex.value = index;
}
function handleSelectLink(val, index) {
// 调起选择链接弹窗
liliDialogRef.value.open("link");
selectedData.value = val;
goodsIndex.value = index;
}
// 回显图片
function callbackSelected(val) {
picModelFlag.value = false;
if (defaultCallbackImageType.value == "image") {
props.data.options.data.image.src = val.url;
} else {
props.data.options.data[defaultCallbackImageType.value][
goodsIndex.value
].img = val.url;
}
}
// 选择商品回调
function selectedGoodsData(val) {
props.data.options.data.hot.list[goodsIndex.value].img = val[0].thumbnail;
props.data.options.data.hot.list[goodsIndex.value].title = val[0].goodsName;
props.data.options.data.hot.list[goodsIndex.value].price = val[0].price;
props.data.options.data.hot.list[goodsIndex.value].url =
formatLinkType(val[0]);
}
// 选择链接回调
function selectedLink(val) {
if (selectedData.value == "image") {
props.data.options.data.image.url =
formatLinkType(val);
} else {
props.data.options.data.list[
goodsIndex.value
].url = formatLinkType(val);
}
}
/*
* 点击不同模块进行编辑
* 判断也很简单,就是第一个第二个模块进行判断
*
* */
function handleSelectModel() {
showModal.value = true;
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.goods-list {
display: flex;
width: 558px;
flex-wrap: wrap;
}
.goods-item {
width: 278px;
height: 277px;
text-align: center;
box-sizing: border-box;
border-right: 1.4px solid #e2e2e2;
&:nth-of-type(3) {
border-top: 1.4px solid #e2e2e2;
}
&:nth-of-type(4) {
border-top: 1.4px solid #e2e2e2;
}
}
.active {
color: #f31947;
}
.goods-name {
margin-top: 23px;
font-size: 25px;
font-weight: normal;
line-height: 30px;
text-align: center;
letter-spacing: 0px;
color: #333333;
}
.goods-img {
width: 190px;
height: 156px;
border-radius: 10px;
margin-top: 19px;
}
.goods-desc {
font-size: 14px;
font-weight: normal;
line-height: 17px;
text-align: center;
margin-top: 11px;
letter-spacing: 0px;
color: #333333;
}
.for-your {
font-size: 35px;
font-weight: normal;
line-height: 42px;
text-align: center;
letter-spacing: 0px;
margin-bottom: 32px;
color: #333333;
}
.column-goods-list {
> div {
margin: 10px;
}
}
.link-src {
margin: 10px 0;
}
.card,
.right-card {
opacity: 1;
background: #ffffff;
position: relative;
&:hover {
.setup-box {
display: block;
}
}
}
.column-img {
width: 100px;
height: 100px;
}
.column-list {
flex-wrap: wrap;
> div {
width: 50%;
}
}
.image {
max-width: 100px;
}
.modal-tab-bar {
> .flex {
align-items: center;
margin: 10px 0;
}
}
.draggable {
margin: 10px 0;
> .flex {
align-items: center;
}
}
.left {
width: 346px;
height: 554px;
border-radius: 9.8px 0px 0px 9.8px;
> img {
max-width: 100%;
height: 100%;
}
}
.right {
width: 839px;
height: 554px;
border-radius: 0px 9.8px 9.8px 0px;
}
.hot-list {
width: 279px;
}
.hot-title {
margin-left: 25px;
line-height: 63px;
font-size: 17px;
font-weight: normal;
letter-spacing: 0px;
color: #333333;
height: 63px;
}
.hot-item {
box-sizing: border-box;
border-top: 1.4px solid #e2e2e2;
height: 122px;
padding-left: 14px;
align-items: center;
> img {
width: 76.3px;
height: 77.7px;
}
}
.hot-goods {
margin-left: 14px;
}
.hot-goods-title {
font-size: 13px;
font-weight: normal;
line-height: 16px;
letter-spacing: 0px;
color: #333333;
}
.hot-price {
margin-top: 10.3px;
font-size: 24px;
font-weight: normal;
line-height: 29px;
letter-spacing: 0px;
color: #f31947;
}
</style>

View File

@@ -1,356 +0,0 @@
<template>
<div class="goods-type-wrapper">
<div class="flex goods-type-line">
<div class="goods-type-title">{{ data.options.title }}</div>
<div class="flex goods-type-labels">
<div
@click="tabCurrentlyIndex = index"
:class="{ active: tabCurrentlyIndex === index }"
class="goods-type-item"
v-for="(item, index) in data.options.labels"
:key="index"
>
{{ item.label }}
</div>
</div>
</div>
<!-- 商品部分 -->
<div class="goods-list flex">
<div
class="goods-list-item"
v-for="(item, index) in data.options.list.filter((subset) => {
return subset.___index == tabCurrentlyIndex;
})"
:key="index"
>
<div class="goods-img">
<img alt="楼层图片" :src="item.img" />
</div>
<div>
<div class="goods-name wes-2">{{ item.title }}</div>
<div class="goods-desc">{{ item.desc }}</div>
</div>
<div class="goods-price">
{{ $filters.unitPrice(item.price, "¥") }}
</div>
</div>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(data.options.list)"
>编辑</el-button
>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<div class="tab-bar">
标题
<el-input style="width: 300px" v-model="data.options.title" />
</div>
<div class="tab-bar" v-if="data.options.labels[tabCurrentlyIndex]">
标签
<el-input
style="width: 300px"
v-model="data.options.labels[tabCurrentlyIndex].label"
/>
</div>
<el-button @click="handleTabsAdd" size="small" class="mb_10">增加</el-button>
<el-tabs
type="card"
v-model="tabIndex"
@tab-click="handleClickTab"
>
<el-tab-pane
v-for="(tab,tabIndex) in data.options.labels"
:key="tabIndex"
:label="tab.label"
:name="tab.___index + ''"
>
<div class="flex flex-a-c">
<el-button class="del-btn" type="primary" @click="handleContextMenuDelete(tab,tabIndex)">删除当前标签</el-button>
<el-button class="add-goods" @click="addCurrentGoods">
添加商品
</el-button>
</div>
<div
v-for="(item, index) in data.options.list.filter((subset) => {
return subset.___index == tabCurrentlyIndex;
})"
:key="index"
class="draggable"
>
<div class="flex">
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="flex column-goods-config">
<div class="column-config">
<div>
标题:
<el-input v-model="item.title" />
</div>
<div>
描述:
<el-input v-model="item.desc" />
</div>
</div>
<el-button @click="delGoods(item)">删除</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "goods" });
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
});
const liliDialog = ref(null);
const tabIndex = ref(0);
const current = ref(0);
const showModal = ref(false);
const selected = ref({}); // 已选数据
const tabCurrentlyIndex = ref(0); // 选项卡索引
onMounted(() => {
tabIndex.value = props.data.options.labels[0].___index;
});
/**
* 算出最大的index 然后索引叠加
*/
function handleTabsAdd() {
let findAllIndex = props.data.options.labels.map((item) => {
return item.___index;
});
let max = Math.max.apply(null, findAllIndex);
props.data.options.labels.push({
label: "标签" + (max + 1),
___index: max + 1,
});
}
// 删除标签
function handleContextMenuDelete(item, index) {
// 删除标签下关联的商品
props.data.options.list.forEach((lab, currently) => {
if (lab.___index == item.___index) {
props.data.options.list.splice(currently, 1);
}
});
// 删除当前标签
props.data.options.labels.splice(index, 1);
}
// 删除商品
function delGoods(val) {
props.data.options.list.forEach((item, i) => {
if (item.title == val.title && item.___index == tabCurrentlyIndex.value) {
props.data.options.list.splice(i, 1);
}
});
}
// 切换选项卡
function handleClickTab(name) {
tabCurrentlyIndex.value = name;
}
// 添加当前选项卡中的商品
function addCurrentGoods() {
liliDialog.value.clearGoodsSelected();
liliDialog.value.open("goods");
}
// 编辑模块
function handleSelectModel(item) {
selected.value = item;
showModal.value = true;
}
// 选择商品回调
function selectedGoodsData(val) {
if (val.length) {
val.forEach((item) => {
const pushGoodsDetail = {
img: item.thumbnail,
price: item.price,
title: item.goodsName,
desc: "",
url: `/goodsDetail?skuId=${item.id}&goodsId=${item.goodsId}`,
___index: tabCurrentlyIndex.value,
};
props.data.options.list.push(pushGoodsDetail);
});
}
}
// 选择链接回调
function selectedLink(val) {
selected.value[current.value].url = formatLinkType(val);
selected.value[current.value].type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.goods-type-wrapper {
position: relative;
}
.del-btn{
margin-left:10px;
}
.tab-bar {
margin-bottom: 20px;
}
.draggable {
padding: 10px;
border-bottom: 1px solid #ededed;
transition: 0.35s;
&:hover {
background-color: #ededed;
}
}
.column-config {
margin-left: 10px;
> * {
margin: 4px;
}
}
.column-img {
width: 100px;
height: 100px;
}
.add-goods {
margin-left: 20px;
margin-bottom: 10px;
}
.goods-list {
position: relative;
flex-wrap: wrap;
justify-content: space-between;
&:hover {
> .setup-box {
display: block;
}
}
}
.column-goods-config {
flex: 2;
align-items: center;
justify-content: space-between;
}
.goods-list-item {
margin-bottom: 14.3px;
width: 287px;
height: 343.7px;
border-radius: 9.8px;
opacity: 1;
cursor: pointer;
background: #ffffff;
transition: 0.35s;
box-shadow: 0px 1px 13px 0px #e5e5e5;
&:hover {
box-shadow: 0px 1px 14px 0px #c5c5c5;
}
}
.goods-img {
text-align: center;
> img {
width: auto;
max-height: 183px;
}
}
.goods-name {
font-size: 18px;
font-weight: normal;
line-height: 22px;
text-align: center;
letter-spacing: 0px;
width: 200px;
margin: 0 auto 12.4px auto;
color: #333333;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-desc {
font-size: 14px;
font-weight: normal;
line-height: 17px;
text-align: center;
letter-spacing: 0px;
margin-bottom: 12.4px;
color: #666666;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-price {
font-size: 25.2px;
font-weight: normal;
line-height: 30px;
text-align: center;
letter-spacing: 0px;
color: #f31947;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-type-line {
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.goods-type-title {
font-size: 31px;
font-weight: normal;
line-height: 37px;
letter-spacing: 0px;
color: #333333;
}
.active {
color: #f31947;
}
.goods-type-labels {
font-size: 21px;
font-weight: normal;
line-height: 25px;
letter-spacing: 0px;
}
.goods-type-item {
margin-left: 28px;
}
</style>

View File

@@ -1,322 +0,0 @@
<template>
<div class="mix-model">
<!-- 左侧 -->
<div class="left-card">
<left :data="data" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel('left')"
>编辑
</el-button>
</div>
</div>
</div>
<!-- 右侧 -->
<div class="right-card">
<right :data="data" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel('right')"
>编辑
</el-button>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div v-if="current">
<div class="modal-tab-bar">
<div>
当前模块 :
<el-button-group>
<el-button :type="current.model == 'goods' ? 'primary' : 'default'"
>商品</el-button
>
<el-button :type="current.model == 'brand' ? 'primary' : 'default'"
>品牌</el-button
>
</el-button-group>
</div>
<div class="flex">
<div>图片 :</div>
<div>
<img class="image" :src="current.data.image.src" alt="" />
<div>
<div>推荐尺寸:{{ current.data.image.size }}</div>
<div class="link-src">
链接地址:
<el-input
style="width: 200px"
v-model="current.data.image.url"
disabled
/>
</div>
<el-button
size="small"
type="primary"
@click="handleSelectImg('default')"
>选择图片
</el-button>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectLink('image')"
>选择链接
</el-button>
</div>
</div>
</div>
<div class="flex" v-if="current.model == 'goods'">
<div>标签 :</div>
<el-input
style="width: 200px"
type="text"
v-model="current.data.badge.label"
/>
<el-input
style="width: 100px; margin-left: 10px"
disabled
type="text"
v-model="current.data.badge.url"
/>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectLink('badge')"
>选择链接
</el-button>
</div>
<div class="flex column-list">
<div
v-for="(item, index) in current.data.list"
:key="index"
class="draggable"
>
<div class="flex">
<img alt="楼层图片" :src="item.img" class="column-img" />
<el-button
size="small"
class="ml_10"
type="primary"
v-if="current.model == 'goods'"
@click="handleSelectGoods('list', index)"
>选择商品
</el-button>
<div>
<div v-if="current.model == 'brand'" style="margin-bottom: 10px">
<el-input disabled v-model="item.url" />
</div>
<el-button
size="small"
class="ml_10"
type="primary"
v-if="current.model == 'brand'"
@click="handleSelectLink('list', index)"
>
选择链接
</el-button>
<el-button
size="small"
class="ml_10"
type="primary"
v-if="current.model == 'brand'"
@click="handleSelectImg('list', index)"
>选择图片
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { getCurrentInstance, nextTick, ref } from "vue";
import left from "./mixs/mix-goods";
import right from "./mixs/mix-brand";
import ossManage from "@/views/sys/oss-manage/ossManage.vue";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "mixModel" });
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
});
const { proxy } = getCurrentInstance();
const showModal = ref(false);
const current = ref(""); //当前选择的模块
const picModelFlag = ref(false);
const selectedData = ref(""); //选中的数据
const goodsIndex = ref(0); // 商品索引
const align = ref("");
const defaultCallbackImageType = ref("");
const liliDialog = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
function handleSelectImg(type, index) {
defaultCallbackImageType.value = type;
goodsIndex.value = index;
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
function handleSelectGoods(val, index) {
liliDialog.value.open("goods");
liliDialog.value.singleGoods();
selectedData.value = val;
goodsIndex.value = index;
}
function handleSelectLink(val, index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
selectedData.value = val;
goodsIndex.value = index;
}
// 回显图片
function callbackSelected(val) {
picModelFlag.value = false;
if (defaultCallbackImageType.value == "default") {
current.value.data.image.src = val.url;
} else {
props.data.options[align.value].data.list[goodsIndex.value].img = val.url;
// props.data.options[align.value].data[selectedData.value][goodsIndex.value].url = val.img
}
}
// 选择商品回调
function selectedGoodsData(val) {
nextTick(() => {
props.data.options[align.value].data.list[goodsIndex.value].img =
val[0].thumbnail;
props.data.options[align.value].data.list[goodsIndex.value].title =
val[0].goodsName;
props.data.options[align.value].data.list[goodsIndex.value].price =
val[0].price;
props.data.options[align.value].data.list[goodsIndex.value].url =
val[0].url;
});
proxy.$forceUpdate();
}
// 选择链接回调
function selectedLink(val) {
if (selectedData.value != "list") {
props.data.options[align.value].data[selectedData.value].url = formatLinkType(val);
}
if (selectedData.value == "list") {
props.data.options[align.value].data[selectedData.value][goodsIndex.value]
.url = formatLinkType(val);
}
}
/*
* 点击不同模块进行编辑
* 判断也很简单,就是第一个第二个模块进行判断
*
* */
function handleSelectModel(alignVal) {
align.value = alignVal;
current.value = props.data.options[alignVal];
showModal.value = true;
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.link-src {
margin: 10px 0;
}
.mix-model {
display: flex;
justify-content: space-between;
}
.left-card,
.right-card {
width: 584px;
height: 344px;
border-radius: 10px;
opacity: 1;
background: #ffffff;
box-shadow: 0px 1px 13px 0px #e5e5e5;
position: relative;
&:hover {
.setup-box {
display: block;
}
}
}
.column-img {
width: 100px;
height: 100px;
}
.column-list {
flex-wrap: wrap;
> div {
width: 50%;
}
}
.image {
max-width: 100px;
}
.modal-tab-bar {
> .flex {
align-items: center;
margin: 10px 0;
}
}
.draggable {
> .flex {
align-items: center;
}
}
</style>

View File

@@ -1,124 +0,0 @@
<template>
<div class="flex" v-if="data.options.right.model == 'brand'">
<div class="left-side" >
<img :src="data.options.right.data.image.src" alt="">
</div>
<div>
<div class="badge-box flex">
<div class="round">
<el-icon><ArrowRight /></el-icon>
</div>
</div>
<div class="flex goods-list">
<div class="goods-item flex" :key="index" v-for="(item,index) in data.options.right.data.list">
<div class="goods-thumbnail">
<img :src="item.img" alt="">
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ArrowRight } from "@element-plus/icons-vue";
defineOptions({ name: "mix-goods" });
defineProps({
data: {
type: Object,
default: () => ({})
}
});
</script>
<style scoped lang="scss">
.goods-detail{
}
.goods-detail-title{
font-size: 13px;
font-weight: normal;
line-height: 16px;
text-align: center;
letter-spacing: 0px;
color: #333333;
}
.goods-detail-price{
font-size: 14px;
font-weight: normal;
line-height: 17px;
letter-spacing: 0px;
color: #F31947;
margin-top: 8px;
margin-bottom:10px;
}
.goods-thumbnail{
margin-left: 7px;
display: flex;
align-items: center;
>img{
width:90px;
height: 90px;
}
}
.badge-box{
margin-top: 33px;
justify-content:right;
>.round{
width: 17.5px;
height: 17.5px;
opacity: 1;
border-radius: 50%;
text-align: center;
line-height: 17.5px;
border: 0.7px solid #333333;
margin-right: 17.5px;
}
}
.goods-list{
flex-wrap: wrap;
justify-content:space-between;
padding: 0 16px;
}
.goods-item{
display: flex;
align-items: center;
justify-content: center;
width:50%;
height: 85px;
border-radius: 9.8px;
opacity: 1;
transition: .35s;
background: #FFFFFF;
margin-bottom:9px;
}
.left-side {
>img{
width: 254px;
height: 344px;
border-radius: 9.8px 0px 0px 9.8px;
opacity: 1;
}
}
.badge {
width: 50px;
height: 27px;
line-height:27px;
border-radius: 13.3px 0px 0px 13.3px;
opacity: 1;
background: #F31947;
font-size: 12.6px;
font-weight: normal;
text-align: center;
letter-spacing: 0px;
color: #FFFFFF;
margin-top: 26px;
margin-bottom:17px;
}
</style>

View File

@@ -1,115 +0,0 @@
<template>
<div class="flex" v-if="data.options.left.model == 'goods'">
<div class="left-side" >
<img :src="data.options.left.data.image.src" alt="">
</div>
<div>
<div class="badge-box flex">
<div class="badge">
{{data.options.left.data.badge.label}}
</div>
</div>
<div class="flex goods-list">
<div class="goods-item flex" :key="index" v-for="(item,index) in data.options.left.data.list">
<div class="goods-thumbnail">
<img :src="item.img" alt="">
</div>
<div class="goods-detail">
<div class="goods-detail-title">{{item.title}}</div>
<div class="goods-detail-price">{{ $filters.unitPrice(item.price, '¥') }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineOptions({ name: "mix-goods" });
defineProps({
data: {
type: Object,
default: () => ({})
}
});
</script>
<style scoped lang="scss">
.goods-detail{
}
.goods-detail-title{
font-size: 13px;
font-weight: normal;
line-height: 16px;
text-align: center;
letter-spacing: 0px;
color: #333333;
}
.goods-detail-price{
font-size: 14px;
font-weight: normal;
line-height: 17px;
letter-spacing: 0px;
color: #F31947;
margin-top: 8px;
margin-bottom:10px;
}
.goods-thumbnail{
margin-left: 7px;
display: flex;
align-items: center;
>img{
width:90px;
height: 90px;
}
}
.badge-box{
justify-content:right;
}
.goods-list{
flex-wrap: wrap;
justify-content:space-between;
padding: 0 16px;
}
.goods-item{
display: flex;
align-items: center;
width: 173.6px;
height: 119px;
border-radius: 9.8px;
opacity: 1;
transition: .35s;
background: #FFFFFF;
margin-bottom:9px;
&:hover{
box-shadow: 0px 1px 6px 0px #E5E5E5;
}
}
.left-side {
>img{
border-radius: 10px 0px 0px 10px;
display: block;
width: 196.7px;
height: 343.7px;
}
}
.badge {
width: 50px;
height: 27px;
line-height:27px;
border-radius: 13.3px 0px 0px 13.3px;
opacity: 1;
background: #F31947;
font-size: 12.6px;
font-weight: normal;
text-align: center;
letter-spacing: 0px;
color: #FFFFFF;
margin-top: 26px;
margin-bottom:17px;
}
</style>

View File

@@ -1,727 +0,0 @@
<template>
<div class="new-goods">
<div class="left">
<div
class="top-header setup-content"
:style="{ background: options.left.bgColor }"
>
<span>{{ options.left.title }}</span>
<span>{{ options.left.secondTitle }} &gt;</span>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(options.left, true)"
>编辑</el-button
>
</div>
</div>
</div>
<div class="content">
<div
class="con-item setup-content"
v-for="(item, index) in options.left.list"
:key="index"
>
<div>
<p>{{ item.name }}</p>
<p class="describe">{{ item.describe }}</p>
</div>
<img :src="item.img" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
</div>
<div class="middle">
<div
class="top-header setup-content"
:style="{ background: options.middle.bgColor }"
>
<span>{{ options.middle.title }}</span>
<span>{{ options.middle.secondTitle }} &gt;</span>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(options.middle, true)"
>编辑</el-button
>
</div>
</div>
</div>
<div class="content">
<div
class="con-item setup-content"
v-for="(item, index) in options.middle.list"
:key="index"
>
<div>
<p>{{ item.name }}</p>
<p class="describe">{{ item.describe }}</p>
</div>
<img :src="item.img" alt="" />
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
</div>
<div class="right">
<div
class="top-header setup-content"
:style="{ background: options.right.bgColor }"
>
<span>{{ options.right.title }}</span>
<span>{{ options.right.secondTitle }} &gt;</span>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(options.right, true)"
>编辑</el-button
>
</div>
</div>
</div>
<div class="content">
<div
class="setup-content"
v-for="(item, index) in options.right.list"
:key="index"
>
<div class="img-wrap">
<img :src="item.img" alt="" />
<div class="jiaobiao" :class="'jiaobiao' + (index + 1)">
{{ index + 1 }}
</div>
</div>
<p>{{ item.name }}</p>
<p>{{ $filters.unitPrice(item.price, "¥") }}</p>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectGoods(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
</div>
<!-- 装修内容 -->
<el-dialog
v-model="showModal"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item modal-form-preview">
<img
class="show-image"
width="160"
height="160"
v-if="selected.size && selected.size.indexOf('160*160') >= 0"
:src="selected.img"
alt
/>
<img
class="show-image"
width="80"
height="80"
v-if="selected.size && selected.size.indexOf('90*90') >= 0"
:src="selected.img"
alt
/>
</div>
<div class="modal-form-item">
<span class="modal-label">图片主标题:</span>
<el-input v-model="selected.name" />
</div>
<div class="modal-form-item">
<span class="modal-label">图片描述:</span>
<el-input v-model="selected.describe" />
</div>
<div class="tips">建议尺寸:{{ selected.size }}</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">图片链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item modal-form-actions">
<el-button size="small" type="primary" @click="handleSelectImg"
>选择图片</el-button
>
<el-button size="small" type="primary" @click="handleSelectGoods('')"
>选择商品</el-button
>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal">取消</el-button>
<el-button type="primary" @click="handleConfirmModal">确定</el-button>
</template>
</el-dialog>
<!-- 装修标题 -->
<el-dialog
v-model="showModal1"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item">
<span class="modal-label">主标题:</span>
<el-input v-model="selected.title" />
</div>
<div class="modal-form-item">
<span class="modal-label">副标题:</span>
<el-input v-model="selected.secondTitle" />
</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">副标题链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item">
<span class="modal-label">背景色:</span>
<div class="modal-color-field">
<el-input v-model="selected.bgColor" />
<el-color-picker v-if="selected.bgColor" v-model="selected.bgColor" />
</div>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal1">取消</el-button>
<el-button type="primary" @click="handleConfirmModal1">确定</el-button>
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, ref } from "vue";
import { formatLinkType } from "@/utils/filters";
import ossManage from "@/views/sys/oss-manage/ossManage";
const props = defineProps({
data: {
type: Object,
default: null,
},
});
const options = ref(props.data.options); // 当前数据
const showModal = ref(false); // modal显隐
const showModal1 = ref(false); // modal显隐
const selected = ref({}); // 弹窗编辑草稿
const editTarget = ref(null); // 确定后写回的原数据
const picModelFlag = ref(false); // 选择图片modal
const liliDialog = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
function cloneItem(item) {
return JSON.parse(JSON.stringify(item || {}));
}
// 装修modal
function handleSelectModel(item, type) {
editTarget.value = item;
selected.value = cloneItem(item);
if (type) {
showModal1.value = true;
} else {
showModal.value = true;
}
}
function handleSelectLink() {
// 调起选择链接弹窗
liliDialog.value.open("link");
}
function handleSelectGoods(item) {
if (item) {
editTarget.value = item;
selected.value = cloneItem(item);
}
liliDialog.value.open("goods", "single");
setTimeout(() => {
liliDialog.value.goodsData = [selected.value];
}, 500);
}
// 选择链接回调
function selectedLink(val) {
if (!selected.value) return;
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
// 选择商品回调
function selectedGoodsData(val) {
if (!val?.length || !selected.value) return;
const goods = val[0];
selected.value.img = goods.thumbnail;
selected.value.price = goods.price;
selected.value.name = goods.goodsName;
selected.value.url = `/goodsDetail?skuId=${goods.id}&goodsId=${goods.goodsId}`;
if (editTarget.value && !showModal.value && !showModal1.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
editTarget.value = null;
selected.value = {};
}
}
function handleSelectImg() {
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
// 选择图片回显
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selected.value.img = val.url;
}
function handleCancelModal() {
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleConfirmModal() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleCancelModal1() {
editTarget.value = null;
selected.value = {};
showModal1.value = false;
}
function handleConfirmModal1() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal1.value = false;
}
</script>
<style lang="scss" scoped>
@import "./setup-box.scss";
.new-goods {
display: flex;
justify-content: space-between;
align-items: stretch;
width: 100%;
box-sizing: border-box;
> div {
width: 393px;
height: 440px;
overflow: hidden;
}
.left > .content {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, 1fr);
flex-direction: unset;
overflow: hidden;
> div {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
display: flex;
padding: 6px 4px 4px 6px;
border-style: solid;
border-color: #eee;
border-width: 0;
border-bottom-width: 1px;
border-right-width: 1px;
img {
width: 64px;
height: 64px;
margin-top: 2px;
flex-shrink: 0;
object-fit: cover;
}
> div {
flex: 1;
min-width: 0;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1.3;
}
.describe {
margin-top: 4px;
font-size: 11px;
}
}
}
> div:nth-child(1) {
grid-row: span 2;
flex-direction: column;
justify-content: space-between;
align-items: center;
text-align: center;
padding: 8px 6px;
img {
width: 120px;
height: 120px;
margin-top: 6px;
}
> div {
flex: unset;
width: 100%;
p {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.describe {
margin-top: 6px;
white-space: normal;
}
}
}
> div:nth-child(2),
> div:nth-child(3),
> div:nth-child(5) {
border-right-width: 0;
}
> div:nth-child(4),
> div:nth-child(5) {
border-bottom-width: 0;
}
}
.middle > .content {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, 1fr);
flex-direction: unset;
> div {
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
border-style: solid;
border-color: #eee;
border-width: 0;
border-bottom-width: 1px;
border-right-width: 1px;
}
> div:nth-child(2n) {
border-right-width: 0;
}
> div:nth-child(n + 5) {
border-bottom-width: 0;
}
}
.right > .content {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
flex-direction: unset;
flex-wrap: unset;
font-size: 12px;
overflow: hidden;
> div {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding: 4px 4px 2px;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
border-bottom: 1px solid #eee;
border-right: 1px solid #eee;
.img-wrap {
position: relative;
width: 80px;
height: 80px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.jiaobiao {
position: absolute;
width: 23px;
height: 23px;
top: 0;
right: 0;
margin: 0;
padding: 0;
max-height: none;
overflow: visible;
display: block;
background: url("../../../assets/festival_icon.png") no-repeat;
color: #fff;
text-align: center;
line-height: 23px;
font-size: 12px;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
}
.jiaobiao1,
.jiaobiao4 {
background-position: -2px -30px;
}
.jiaobiao2,
.jiaobiao5 {
background-position: -31px -30px;
}
.jiaobiao3,
.jiaobiao6 {
background-position: -60px -30px;
}
}
> p:nth-child(2) {
width: 100%;
height: auto;
max-height: 32px;
line-height: 16px;
margin-top: 4px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-align: center;
}
> p:nth-child(3) {
color: $theme_color;
margin-top: 2px;
line-height: 1.2;
flex-shrink: 0;
}
}
> div:nth-child(3n) {
border-right: none;
}
> div:nth-child(n + 4) {
border-bottom: none;
}
}
.top-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 10px;
background: #c43d7e;
color: #fff;
span:nth-child(1) {
font-size: 20px;
}
span:nth-child(2) {
font-size: 12px;
}
}
.content {
padding: 10px 12px 0;
display: flex;
flex-wrap: wrap;
flex-direction: column;
height: 370px;
box-sizing: border-box;
overflow: hidden;
}
.con-item {
width: 185px;
height: 120px;
display: flex;
padding-left: 10px;
padding-top: 10px;
img {
width: 90px;
height: 90px;
margin-top: 10px;
}
}
.middle > .content .con-item {
width: 100%;
max-width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
padding: 6px 4px 4px 6px;
img {
width: 64px;
height: 64px;
margin-top: 2px;
flex-shrink: 0;
}
> div {
flex: 1;
min-width: 0;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1.3;
}
.describe {
margin-top: 4px;
font-size: 11px;
}
}
}
.left > .content .con-item {
width: 100%;
max-width: 100%;
height: 100%;
}
.describe {
color: #999;
font-size: 12px;
margin-top: 15px;
}
}
.modal-top-advert {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
.modal-form-item {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 16px;
.modal-label {
flex-shrink: 0;
width: 100px;
text-align: right;
padding-right: 12px;
}
.el-input {
flex: 1;
}
}
.modal-form-preview {
justify-content: center;
margin-bottom: 8px;
}
.modal-form-link {
align-items: flex-start;
.modal-label {
padding-top: 8px;
}
.modal-link-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
.outsideUrl {
flex: 1;
min-width: 0;
}
}
}
.modal-form-actions {
padding-left: 112px;
gap: 10px;
}
.modal-color-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
.el-input {
flex: 1;
}
}
.tips {
padding-left: 112px;
margin-bottom: 16px;
color: #999;
font-size: 12px;
}
}
</style>

View File

@@ -1,321 +0,0 @@
<template>
<div class="not-enough">
<div class="nav-bar setup-content">
<ul class="nav-list">
<li v-for="(item, index) in conData.options.navList" :class="currentIndex===index?'curr':''" @click="changeCurr(index)" :key="index">
<p>{{item.title}}</p>
<p>{{item.desc}}</p>
</li>
</ul>
<div class="setup-box nav-setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel">编辑</el-button>
</div>
</div>
</div>
<div class="content" v-if="showContent">
<div v-for="(item, index) in conData.options.list[currentIndex]" :key="index" class="setup-content">
<img class="goods-img" :src="item.img" :alt="item.name">
<p>{{item.name}}</p>
<p>
<span>{{ $filters.unitPrice(item.price, '¥') }}</span>
</p>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectGoods(item)">编辑</el-button>
</div>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@close="handleCancelModal">
<div class="modal-tab-bar" v-if="draftNavList.length">
<el-button type="primary" size='small' @click="handleAddNav">添加分类</el-button>
<table cellspacing="0">
<thead>
<tr>
<th width="250">主标题</th>
<th width="250">描述</th>
<th width="250">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in draftNavList" :key="index">
<td><el-input v-model="item.title" /></td>
<td><el-input v-model="item.desc" /></td>
<td v-if="index!=0">
<el-button type="danger" size="small" @click="handleDelNav(index)">删除</el-button>
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<el-button @click="handleCancelModal">取消</el-button>
<el-button type="primary" @click="handleConfirmModal">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showGoodsModal"
title="装修"
width="600"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@close="handleCancelGoodsModal">
<div class="modal-top-advert">
<div class="modal-form-item modal-form-preview" v-if="selected.img">
<img class="show-image goods-preview" :src="selected.img" alt />
</div>
<div class="modal-form-item" v-if="selected.name">
<span class="modal-label">商品名称:</span>
<span>{{ selected.name }}</span>
</div>
<div class="modal-form-item" v-if="selected.price">
<span class="modal-label">商品价格:</span>
<span>{{ $filters.unitPrice(selected.price, '¥') }}</span>
</div>
<div class="modal-form-item modal-form-actions">
<el-button size="small" type="primary" @click="handleOpenGoodsPicker">选择商品</el-button>
</div>
</div>
<template #footer>
<el-button @click="handleCancelGoodsModal">取消</el-button>
<el-button type="primary" @click="handleConfirmGoodsModal">确定</el-button>
</template>
</el-dialog>
<liliDialog
ref="liliDialog"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
const props = defineProps({
data:{
type: Object,
default: null
}
});
const emit = defineEmits(['content']);
const currentIndex = ref(0);
const conData = ref(props.data);
const selected = ref({});
const editTarget = ref(null);
const showModal = ref(false);
const showGoodsModal = ref(false);
const showContent = ref(true);
const draftNavList = ref([]);
const draftGoodsList = ref([]);
const liliDialog = ref(null);
watch(() => props.data, (val) => {
conData.value = val;
});
watch(conData, (val) => {
emit('content', val);
});
function cloneList(list) {
return JSON.parse(JSON.stringify(list || []));
}
function cloneItem(item) {
return JSON.parse(JSON.stringify(item || {}));
}
function changeCurr(index){
currentIndex.value = index;
}
function handleSelectModel () {
draftNavList.value = cloneList(conData.value.options.navList);
draftGoodsList.value = cloneList(conData.value.options.list);
showModal.value = true;
}
function handleSelectGoods(item) {
editTarget.value = item;
selected.value = cloneItem(item);
showGoodsModal.value = true;
}
function handleOpenGoodsPicker() {
liliDialog.value.open('goods', 'single');
setTimeout(() => {
liliDialog.value.goodsData = [selected.value];
}, 500);
}
function selectedGoodsData(val){
if (!val?.length) return;
const goods = val[0];
selected.value.img = goods.thumbnail;
selected.value.price = goods.price;
selected.value.name = goods.goodsName;
selected.value.url = `/goodsDetail?skuId=${goods.id}&goodsId=${goods.goodsId}`;
}
function handleDelNav(index){
draftNavList.value.splice(index,1);
draftGoodsList.value.splice(index,1);
}
function handleAddNav(){
draftNavList.value.push({ title:'', desc:'' });
draftGoodsList.value.push([
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
{ img:'', name:'', price:0, url:'' },
]);
}
function handleCancelModal() {
draftNavList.value = [];
draftGoodsList.value = [];
showModal.value = false;
}
function handleConfirmModal() {
conData.value.options.navList = cloneList(draftNavList.value);
conData.value.options.list = cloneList(draftGoodsList.value);
draftNavList.value = [];
draftGoodsList.value = [];
showContent.value = false;
nextTick(() => {
showContent.value = true;
});
showModal.value = false;
}
function handleCancelGoodsModal() {
editTarget.value = null;
selected.value = {};
showGoodsModal.value = false;
}
function handleConfirmGoodsModal() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showGoodsModal.value = false;
}
</script>
<style lang="scss" scoped>
@import './setup-box.scss';
.not-enough {
width: 100%;
}
.nav-bar{
width: 100%;
margin-bottom: 10px;
background-color: rgb(218, 217, 217);
height: 60px;
position: relative;
}
.nav-setup-box {
left: auto;
right: 0;
width: 100px;
}
.nav-list {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
li{
padding: 0 30px;
text-align: center;
p:nth-child(1){
font-size: 16px;
border-radius: 50px;
padding: 0 7px;
margin: 0;
line-height: 1.2;
}
p:nth-child(2){
font-size: 14px;
color: #999;
margin: 2px 0 0;
line-height: 1.2;
}
&:hover{
p{
color: $theme_color;
}
cursor: pointer;
}
border-right: 1px solid #eee;
}
li:last-of-type{
border: none;
}
.curr{
p:nth-child(1){
background-color: $theme_color;
color: #fff;
}
p:nth-child(2){
color: $theme_color;
}
}
}
.content{
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
width: 100%;
box-sizing: border-box;
>div{
padding: 10px;
box-sizing: border-box;
border: 1px solid #eee;
min-width: 0;
.goods-img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
}
p:nth-of-type(1){
overflow: hidden;
width: 100%;
white-space: nowrap;
text-overflow:ellipsis;
margin: 10px 0 5px 0;
}
p:nth-of-type(2){
color: $theme_color;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
span:nth-child(2){
text-decoration: line-through;
font-size: 12px;
color: #999;
}
}
}
}
.goods-preview {
width: 210px;
height: 210px;
object-fit: cover;
}
</style>

View File

@@ -1,163 +0,0 @@
<template>
<div class="line flex flex-j-sb">
<div class="column" v-for="(item,index) in data.options.list" :key="index">
<div v-if="!item.img" class="placeholder">占位符</div>
<img alt="楼层图片" v-else :src="item.img" class="three-column-img">
</div>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(data.options.list)"
>编辑</el-button
>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<div v-for="(item,index) in data.options.list" :key="index">
<img alt="楼层图片" :src="item.img" class="three-column-img">
<div>推荐尺寸:{{item.size}}</div>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectImg(index)"
>选择图片</el-button
>
<el-button
size="small"
class="ml_10"
type="primary"
@click="handleSelectLink(index)"
>选择链接</el-button
>
</div>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, ref } from "vue";
import { formatLinkType } from "@/utils/filters";
import ossManage from "@/views/sys/oss-manage/ossManage";
defineOptions({ name: "oneRowThreeColumns" });
defineProps({
data: {
type: Object,
default: () => ({})
}
});
const showModal = ref(false);
const selected = ref({}); // 已选数据
const picModelFlag = ref(false);
const current = ref(0);
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
// 回显图片
function callbackSelected(val) {
picModelFlag.value = false;
selected.value[current.value].img = val.url;
}
// 编辑模块
function handleSelectModel(item) {
selected.value = item;
showModal.value = true;
}
// 选择商品回调
function selectedGoodsData(val) {
let goods = val[0];
selected.value.img = goods.thumbnail;
selected.value.price = goods.price;
selected.value.name = goods.goodsName;
selected.value.url = `/goodsDetail?skuId=${goods.id}&goodsId=${goods.goodsId}`;
}
// 选择链接回调
function selectedLink(val) {
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
function handleSelectLink(index) {
// 调起选择链接弹窗
liliDialogRef.value.open("link");
current.value = index;
}
function handleSelectImg(index) {
// 选择图片
current.value = index;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.three-column-img{
width:385px;
height: 165px
}
.line:hover{
>.setup-box{
display: block;
}
}
.placeholder{
background: #666;
width: 100%;
height: 100%;
}
.line{
position: relative;
justify-content: space-between;
}
.column{
width: 385px;
height: 165px;
>img{
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -1,264 +0,0 @@
<template>
<div class="goods-type-wrapper">
<!-- 商品部分 -->
<div class="goods-list flex">
<div
class="goods-list-item"
v-for="(item, index) in data.options.list"
:key="index"
>
<div>
<div class="goods-name wes-2">{{ item.title }}</div>
<div class="goods-desc">{{ item.desc }}</div>
</div>
<div class="goods-img">
<img alt="楼层图片" :src="item.img" />
</div>
</div>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(data.options.list)"
>编辑</el-button
>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800"
:close-on-click-modal="false"
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<div class="flex flex-a-c">
<el-button class="add-goods" @click="addCurrentGoods">
添加商品
</el-button>
</div>
<div
v-for="(item, index) in data.options.list"
:key="index"
class="draggable"
>
<div class="flex">
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="flex column-goods-config">
<div class="column-config">
<div>
标题:
<el-input v-model="item.title" />
</div>
<div>
描述:
<el-input v-model="item.desc" />
</div>
</div>
<el-button @click="delGoods(item,index)">删除</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { formatLinkType } from "@/utils/filters";
defineOptions({ name: "onlyGoodsList" });
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
});
const current = ref(0);
const showModal = ref(false);
const selected = ref({}); // 已选数据
const liliDialog = ref(null);
// 删除商品
function delGoods(val, i) {
props.data.options.list.splice(i, 1);
}
// 添加当前选项卡中的商品
function addCurrentGoods() {
liliDialog.value.open("goods");
}
// 编辑模块
function handleSelectModel(item) {
selected.value = item;
showModal.value = true;
}
// 选择商品回调
function selectedGoodsData(val) {
if (val.length) {
val.forEach((item) => {
const pushGoodsDetail = {
img: item.thumbnail,
price: item.price,
title: item.goodsName,
desc: "",
url: `/goodsDetail?skuId=${item.id}&goodsId=${item.goodsId}`,
};
props.data.options.list.push(pushGoodsDetail);
});
}
}
// 选择链接回调
function selectedLink(val) {
selected.value[current.value].url = formatLinkType(val);
selected.value[current.value].type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
</script>
<style scoped lang="scss">
@import "./setup-box.scss";
.goods-type-wrapper {
position: relative;
}
.del-btn{
margin-left:10px;
}
.tab-bar {
margin-bottom: 20px;
}
.draggable {
padding: 10px;
border-bottom: 1px solid #ededed;
transition: 0.35s;
&:hover {
background-color: #ededed;
}
}
.column-config {
margin-left: 10px;
> * {
margin: 4px;
}
}
.column-img {
width: 100px;
height: 100px;
}
.add-goods {
margin-left: 20px;
margin-bottom: 10px;
}
.goods-list {
position: relative;
flex-wrap: wrap;
justify-content: space-between;
&:hover {
> .setup-box {
display: block;
}
}
}
.column-goods-config {
flex: 2;
align-items: center;
justify-content: space-between;
}
.goods-list-item {
padding-top: 34.8px;
margin-bottom: 14.3px;
width: 287px;
height: 343.7px;
border-radius: 9.8px;
opacity: 1;
cursor: pointer;
background: #ffffff;
transition: 0.35s;
box-shadow: 0px 1px 13px 0px #e5e5e5;
&:hover {
box-shadow: 0px 1px 14px 0px #c5c5c5;
}
}
.goods-img {
text-align: center;
> img {
width: auto;
max-height: 183px;
}
}
.goods-name {
margin-bottom: 11.9px;
font-size: 25px;
font-weight: normal;
line-height: 30px;
text-align: center;
letter-spacing: 0px;
color: #333333;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-desc {
margin-bottom: 30px;
font-size: 16px;
font-weight: normal;
line-height: 19px;
text-align: center;
letter-spacing: 0px;
color: #666666;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-price {
font-size: 25.2px;
font-weight: normal;
line-height: 30px;
text-align: center;
letter-spacing: 0px;
color: #f31947;
-webkit-text-stroke: #979797 0.7px; /* 浏览器可能不支持 */
}
.goods-type-line {
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.goods-type-title {
font-size: 31px;
font-weight: normal;
line-height: 37px;
letter-spacing: 0px;
color: #333333;
}
.active {
color: #f31947;
}
.goods-type-labels {
font-size: 21px;
font-weight: normal;
line-height: 25px;
letter-spacing: 0px;
}
.goods-type-item {
margin-left: 28px;
}
</style>

View File

@@ -1,581 +0,0 @@
<template>
<div class="recommend">
<div class="recommend-left">
<div
class="head-recommend setup-content"
:style="{ background: msgLeft.bgColor }"
>
<span>{{ msgLeft.title }}</span>
<span>{{ msgLeft.secondTitle }}&gt;</span>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(msgLeft, true)"
>编辑</el-button
>
</div>
</div>
</div>
<div class="content-left">
<div class="setup-content">
<img :src="msgLeft.list[0].img" width="160" height="160" alt="" />
<div class="margin-left">{{ msgLeft.list[0].name }}</div>
<div class="margin-left">{{ msgLeft.list[0].describe }}</div>
<el-button
size="small"
:style="{ background: msgLeft.bgColor }"
class="fz_12 view-btn"
>点击查看</el-button
>
<div class="setup-box">
<div>
<el-button
size="small"
@click.stop="handleSelectModel(msgLeft.list[0])"
>编辑</el-button
>
</div>
</div>
</div>
<div>
<template v-for="(item, index) in msgLeft.list">
<div v-if="index != 0" :key="index" class="setup-content">
<img :src="item.img" width="80" height="80" alt="" />
<div>
<div>{{ item.name }}</div>
<div>{{ item.describe }}</div>
</div>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="recommend-right">
<div
class="head-recommend setup-content"
:style="{ background: msgRight.bgColor }"
>
<span>{{ msgRight.title }}</span>
<span>{{ msgRight.secondTitle }}&gt;</span>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(msgRight, true)"
>编辑</el-button
>
</div>
</div>
</div>
<div class="content-right">
<div
v-for="(item, index) in msgRight.list"
:key="index"
class="setup-content"
>
<div class="right-item">
<div>
<span :style="{ background: msgRight.bgColor }">{{
item.name
}}</span>
<span>{{ item.describe }}</span>
</div>
<div class="right-img">
<img :src="item.img" alt="" />
</div>
</div>
<div class="setup-box">
<div>
<el-button size="small" @click.stop="handleSelectModel(item)"
>编辑</el-button
>
</div>
</div>
</div>
</div>
</div>
<el-dialog
v-model="showModal"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item modal-form-preview">
<img
class="show-image"
width="160"
height="160"
v-if="selected.size && selected.size.indexOf('160*160') >= 0"
:src="selected.img"
alt
/>
<img
class="show-image"
width="80"
height="80"
v-if="selected.size && selected.size.indexOf('80*80') >= 0"
:src="selected.img"
alt
/>
<img
class="show-image"
width="100"
height="100"
v-if="selected.size && selected.size.indexOf('100*100') >= 0"
:src="selected.img"
alt
/>
</div>
<div class="modal-form-item">
<span class="modal-label">图片主标题:</span>
<el-input v-model="selected.name" />
</div>
<div class="modal-form-item">
<span class="modal-label">图片描述:</span>
<el-input v-model="selected.describe" />
</div>
<div class="tips">建议尺寸:{{ selected.size }}</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">图片链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item modal-form-actions">
<el-button size="small" type="primary" @click="handleSelectImg"
>选择图片</el-button
>
<el-button size="small" type="primary" @click="handleSelectGoods"
>选择商品</el-button
>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal">取消</el-button>
<el-button type="primary" @click="handleConfirmModal">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showModal1"
title="装修"
width="800px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="modal-top-advert">
<div class="modal-form-item">
<span class="modal-label">主标题:</span>
<el-input v-model="selected.title" />
</div>
<div class="modal-form-item">
<span class="modal-label">副标题:</span>
<el-input v-model="selected.secondTitle" />
</div>
<div class="modal-form-item modal-form-link">
<span class="modal-label">副标题链接:</span>
<div class="modal-link-field">
<el-input
class="outsideUrl"
v-model="selected.url"
:disabled="!!selected.type && selected.type !== 'link'"
placeholder="https://"
/>
<el-button size="small" type="primary" @click="handleSelectLink"
>选择链接</el-button
>
</div>
</div>
<div class="modal-form-item">
<span class="modal-label">背景色:</span>
<div class="modal-color-field">
<el-input v-model="selected.bgColor" />
<el-color-picker v-if="selected.bgColor" v-model="selected.bgColor" />
</div>
</div>
</div>
<template #footer>
<el-button @click="handleCancelModal1">取消</el-button>
<el-button type="primary" @click="handleConfirmModal1">确定</el-button>
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, reactive, ref } from "vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { formatLinkType } from "@/utils/filters";
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
});
const msgLeft = reactive(props.data.options.contentLeft); // 左侧数据
const msgRight = reactive(props.data.options.contentRight); // 右侧数据
const showModal = ref(false); // modal显隐
const showModal1 = ref(false); // modal显隐
const selected = ref({}); // 弹窗编辑草稿
const editTarget = ref(null); // 确定后写回的原数据
const picModelFlag = ref(false); // 图片选择
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
function cloneItem(item) {
return JSON.parse(JSON.stringify(item || {}));
}
// 编辑
function handleSelectModel(item, type) {
editTarget.value = item;
selected.value = cloneItem(item);
if (type) {
showModal1.value = true;
} else {
showModal.value = true;
}
}
function handleSelectLink(item, index) {
// 调起选择链接弹窗
liliDialogRef.value.open("link");
}
function handleSelectGoods(item) {
// 调起选择商品
liliDialogRef.value.open("goods", "single");
}
// 选择链接回调
function selectedLink(val) {
selected.value.url = formatLinkType(val);
selected.value.type =
val.___type === "other" && val.url === "" ? "link" : "other";
}
// 选择商品回调
function selectedGoodsData(val) {
let goods = val[0];
selected.value.img = goods.thumbnail;
selected.value.price = goods.price;
selected.value.name = goods.goodsName;
selected.value.url = `/goodsDetail?skuId=${goods.id}&goodsId=${goods.goodsId}`;
}
function handleSelectImg() {
// 选择图片
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
}
});
}
// 选择图片回调
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selected.value.img = val.url;
}
function handleCancelModal() {
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleConfirmModal() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal.value = false;
}
function handleCancelModal1() {
editTarget.value = null;
selected.value = {};
showModal1.value = false;
}
function handleConfirmModal1() {
if (editTarget.value) {
Object.assign(editTarget.value, cloneItem(selected.value));
}
editTarget.value = null;
selected.value = {};
showModal1.value = false;
}
</script>
<style lang="scss" scoped>
@import "./setup-box.scss";
.recommend {
display: flex;
justify-content: space-between;
.recommend-left {
width: 595px;
.content-left {
display: flex;
align-items: stretch;
padding-top: 10px;
font-size: 12px;
box-sizing: content-box;
> div:nth-child(1) {
width: 189px;
border-right: 1px solid #eee;
height: 360px;
display: flex;
flex-direction: column;
box-sizing: border-box;
img {
margin: 40px 0 0 15px;
}
.margin-left {
margin-left: 15px;
width: 145px;
}
div:nth-of-type(1) {
font-weight: bold;
border-top: 1px solid #eee;
padding-top: 10px;
padding-bottom: 10px;
}
div:nth-of-type(2) {
color: #999;
}
.view-btn {
align-self: center;
width: 145px;
margin-top: auto;
margin-bottom: 15px;
color: #fff;
}
}
> div:nth-child(2) {
width: 405px;
height: 360px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
box-sizing: border-box;
> div {
display: flex;
align-items: center;
width: 200px;
height: 120px;
img {
margin: 0 10px;
}
> div:nth-child(2) {
// margin: 0 10px;
:nth-child(2) {
color: #449dae;
}
}
}
}
}
}
.recommend-right {
width: 595px;
.head-recommend {
background: #a25684;
}
.content-right {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
height: 360px;
padding-top: 10px;
box-sizing: content-box;
> div {
width: 100%;
height: 100%;
text-align: center;
padding: 0;
border-right: 1px solid #eee;
border-bottom: 1px solid #eee;
box-sizing: border-box;
&:nth-child(2n) {
border-right: none;
}
&:nth-child(n + 3) {
border-bottom: none;
}
.right-item {
border-bottom: none;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
margin: 0;
padding: 10px 15px;
font-size: 12px;
box-sizing: border-box;
> div:nth-child(1) {
flex: 1;
min-width: 0;
margin-top: 0;
text-align: left;
span:nth-child(1) {
color: #fff;
border-radius: 10px;
padding: 0 5px;
background-color: #a25684;
display: block;
max-width: 120px;
overflow: hidden;
white-space: nowrap;
margin: 0 0 10px;
}
span:nth-child(2) {
font-size: 12px;
color: #666;
display: block;
}
}
.right-img {
width: 100px;
height: 100px;
flex-shrink: 0;
text-align: center;
margin: 0;
img {
max-height: 100px;
max-width: 100px;
}
}
}
}
}
}
.head-recommend {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 10px;
background: #449dae;
color: #fff;
span:nth-child(1) {
font-size: 20px;
}
span:nth-child(2) {
font-size: 12px;
}
}
}
.modal-top-advert {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
.modal-form-item {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 16px;
.modal-label {
flex-shrink: 0;
width: 100px;
text-align: right;
padding-right: 12px;
}
.el-input {
flex: 1;
}
}
.modal-form-preview {
justify-content: center;
margin-bottom: 8px;
}
.modal-form-link {
align-items: flex-start;
.modal-label {
padding-top: 8px;
}
.modal-link-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
.outsideUrl {
flex: 1;
min-width: 0;
}
}
}
.modal-form-actions {
padding-left: 112px;
gap: 10px;
}
.modal-color-field {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
.el-input {
flex: 1;
}
}
.tips {
padding-left: 112px;
margin-bottom: 16px;
color: #999;
font-size: 12px;
}
}
</style>

View File

@@ -1,294 +0,0 @@
<template>
<div class="seckill">
<div class="desc">秒杀商品需要在促销活动中添加商品,有商品时才会在首页展示</div>
<div class="aside">
<div class="title">{{ actName }}</div>
<div class="hour">
<span>{{ currHour }}:00</span>点场 倒计时
</div>
<div class="count-down" v-if="actStatus === 1">
<span>{{ hours }}</span
><span>{{ minutes }}</span
><span>{{ seconds }}</span>
</div>
<div class="act-status" v-else>
{{ actStatus == 0 ? "未开始" : "已结束" }}
</div>
</div>
<div class="section">
<div ref="swiperEl" class="swiper">
<div class="swiper-wrapper">
<div
class="swiper-slide"
v-for="(item, index) in options.list[0].goodsList"
:key="index"
>
<div class="content">
<img :src="item.img" width="140" height="140" :alt="item.name" />
<div class="ellipsis">{{ item.name }}</div>
<div>
<span>{{ $filters.unitPrice(item.price, "¥") }}</span>
<span>{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import Swiper from "swiper";
import "swiper/swiper-bundle.css";
const props = defineProps({
data: {
type: Object,
default: null,
},
});
const options = ref(props.data.options); // 当前数据
const actStatus = ref(0); // 0 未开始 1 进行中 2 已结束
const actName = ref("限时秒杀");
const currHour = ref("00"); // 当前秒杀场
const diffSeconds = ref(0); // 倒地时
const hours = ref("00"); // 小时
const minutes = ref("00"); // 分钟
const seconds = ref("00"); // 秒
const interval = ref(undefined); // 定时器
const swiperInstance = ref(null);
const swiperEl = ref(null);
watch(diffSeconds, (val) => {
const h = Math.floor(val / 3600);
// 当前秒数 / 60,向下取整
// 获取到所有分钟数 3600 / 60 = 60分钟
// 对60取模,超过小时数的分钟数
const m = Math.floor(val / 60) % 60;
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
const s = val % 60;
hours.value = h < 10 ? "0" + h : h;
minutes.value = m < 10 ? "0" + m : m;
seconds.value = s < 10 ? "0" + s : s;
if (val === 0) {
clearInterval(interval.value);
hours.value = 0;
minutes.value = 0;
seconds.value = 0;
countDown(options.value.list);
}
});
onMounted(() => {
countDown(options.value.list);
initSwiper();
});
onBeforeUnmount(() => {
clearInterval(interval.value);
if (swiperInstance.value) {
swiperInstance.value.destroy(true, true);
swiperInstance.value = null;
}
});
function initSwiper() {
if (!swiperEl.value) {
return;
}
swiperInstance.value = new Swiper(swiperEl.value, {
slidesPerView: 5,
autoplay: true,
loop: true,
});
}
// 倒计时
function countDown(list) {
/**
* 默认倒计时两小时
* 如果没有开始,则显示未开始
* 进行中显示倒计时
* 今天的秒杀结束则显示已结束
*/
let nowHour = new Date().getHours();
if (nowHour < Number(list[0].time)) {
// 活动未开始
currHour.value = list[0].time;
actStatus.value = 0;
} else if (nowHour >= Number(list[list.length - 1].time + 2)) {
// 活动已结束
actStatus.value = 2;
currHour.value = list[list.length - 1].time;
} else {
// 活动进行中
actStatus.value = 1;
for (let i = 0; i < list.length; i++) {
if (nowHour == Number(list[i].time)) {
currHour.value = list[i].time;
}
if (
nowHour > Number(list[i].time) &&
nowHour < Number(list[i].time + 2)
) {
currHour.value = list[i].time;
}
}
// 当前0点时间戳
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
// 活动倒计时
diffSeconds.value = Math.floor(
(zeroTime +
3600 * 1000 * (currHour.value + 2) -
new Date().getTime()) /
1000
);
interval.value = setInterval(() => {
diffSeconds.value--;
}, 1000);
}
}
</script>
<style lang="scss" scoped>
.seckill {
width: 100%;
height: 260px;
display: flex;
position: relative;
.desc{
position: absolute;
color: $theme_color;
left: 200px;
top: 5px;
}
.aside {
overflow: hidden;
width: 190px;
height: 100%;
color: #fff;
background-image: url("../../../assets/seckillBg.png");
.title {
width: 100%;
text-align: center;
font-size: 28px;
margin-top: 31px;
}
.hour {
margin-top: 90px;
text-align: center;
span {
font-size: 18px;
}
}
.count-down {
margin: 10px 0 0 30px;
> span {
position: relative;
float: left;
width: 30px;
height: 30px;
text-align: center;
background-color: #2f3430;
margin-right: 20px;
color: white;
font-size: 20px;
&::after {
content: ":";
display: block;
position: absolute;
right: -20px;
font-weight: bolder;
font-size: 18px;
width: 20px;
height: 100%;
top: 0;
}
}
> span:last-child::after {
content: "";
}
}
.act-status {
margin: 10px 0 0 65px;
font-size: 20px;
}
}
.section {
width: 1000px;
// background: #efefef;
.swiper-slide {
height: 260px;
.content {
width: 200px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
&::after {
content: "";
display: block;
position: absolute;
top: 50%;
right: 0;
width: 1px;
height: 200px;
transform: translateY(-50%);
background: linear-gradient(180deg, white, #eeeeee, white);
}
img {
margin-top: 30px;
}
> div {
width: 160px;
margin-top: 10px;
font-size: 12px;
position: relative;
}
> div:nth-of-type(1):hover {
color: $theme_color;
cursor: pointer;
}
> div:nth-of-type(2) {
border: 1px solid $theme_color;
line-height: 24px;
display: flex;
text-align: center;
span:nth-child(1) {
color: #fff;
font-size: 16px;
width: 92px;
background-color: $theme_color;
position: relative;
&::before {
content: " ";
width: 0;
height: 0;
border-color: transparent white transparent transparent;
border-style: solid;
border-width: 24px 8px 0 0;
position: absolute;
top: 0;
left: 84px;
}
}
span:nth-child(2) {
color: #999;
width: 66px;
text-decoration: line-through;
}
}
}
}
}
}
</style>

View File

@@ -1,293 +0,0 @@
<template>
<div class="seckill-wrap">
<div class="desc">秒杀商品需要在促销活动中添加商品,有商品时才会在首页展示</div>
<div class="seckill">
<div class="aside">
<div class="title">{{ actName }}</div>
<div class="time-block">
<div class="hour">
<span>{{ currHour }}:00</span>点场 倒计时
</div>
<div class="count-down" v-if="actStatus === 1">
<span>{{ hours }}</span>
<span>{{ minutes }}</span>
<span>{{ seconds }}</span>
</div>
<div class="act-status" v-else>
{{ actStatus == 0 ? "未开始" : "已结束" }}
</div>
</div>
</div>
<div class="section">
<div class="seckill-list">
<div
class="seckill-item"
v-for="(item, index) in options.list[0].goodsList"
:key="index"
>
<div class="content">
<img :src="item.img" width="140" height="140" :alt="item.name" />
<div class="ellipsis">{{ item.name }}</div>
<div class="price-row">
<span>{{ $filters.unitPrice(item.price, "¥") }}</span>
<span>{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps({
data: {
type: Object,
default: null,
},
});
const options = ref(props.data.options);
const actStatus = ref(0);
const actName = ref("限时秒杀");
const currHour = ref("00");
const diffSeconds = ref(0);
const hours = ref("00");
const minutes = ref("00");
const seconds = ref("00");
let interval = undefined;
watch(diffSeconds, (val) => {
const h = Math.floor(val / 3600);
const m = Math.floor(val / 60) % 60;
const s = val % 60;
hours.value = h < 10 ? "0" + h : h;
minutes.value = m < 10 ? "0" + m : m;
seconds.value = s < 10 ? "0" + s : s;
if (val === 0) {
clearInterval(interval);
hours.value = 0;
minutes.value = 0;
seconds.value = 0;
countDown(options.value.list);
}
});
onMounted(() => {
countDown(options.value.list);
});
onBeforeUnmount(() => {
clearInterval(interval);
});
function countDown(list) {
const nowHour = new Date().getHours();
if (nowHour < Number(list[0].time)) {
currHour.value = list[0].time;
actStatus.value = 0;
} else if (nowHour >= Number(list[list.length - 1].time + 2)) {
actStatus.value = 2;
currHour.value = list[list.length - 1].time;
} else {
actStatus.value = 1;
for (let i = 0; i < list.length; i++) {
if (nowHour == Number(list[i].time)) {
currHour.value = list[i].time;
}
if (
nowHour > Number(list[i].time) &&
nowHour < Number(list[i].time + 2)
) {
currHour.value = list[i].time;
}
}
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
diffSeconds.value = Math.floor(
(zeroTime +
3600 * 1000 * (currHour.value + 2) -
new Date().getTime()) /
1000
);
interval = setInterval(() => {
diffSeconds.value--;
}, 1000);
}
}
</script>
<style lang="scss" scoped>
.seckill-wrap {
width: 100%;
}
.desc {
color: $theme_color;
font-size: 12px;
line-height: 1.5;
padding: 0 0 8px;
}
.seckill {
width: 100%;
height: 260px;
display: flex;
background-color: #fff;
box-sizing: border-box;
}
.aside {
flex-shrink: 0;
overflow: hidden;
width: 190px;
height: 100%;
color: #fff;
background-image: url("../../../assets/seckillBg.png");
background-repeat: no-repeat;
background-position: center top;
.title {
width: 100%;
text-align: center;
font-size: 28px;
margin-top: 31px;
}
.time-block {
margin-top: 78px;
text-align: center;
}
.hour {
text-align: center;
span {
font-size: 18px;
}
}
.count-down {
margin-top: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
> span {
position: relative;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
background-color: #2f3430;
margin-right: 20px;
color: white;
font-size: 20px;
&::after {
content: ":";
position: absolute;
right: -20px;
font-weight: bolder;
font-size: 18px;
width: 20px;
top: 0;
}
}
> span:last-child {
margin-right: 0;
&::after {
content: "";
}
}
}
.act-status {
margin-top: 10px;
font-size: 20px;
}
}
.section {
flex: 1;
min-width: 0;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
background-color: #fff;
.seckill-list {
display: flex;
height: 100%;
min-width: min-content;
}
.seckill-item {
flex: 0 0 200px;
height: 100%;
.content {
width: 200px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
box-sizing: border-box;
&::after {
content: "";
position: absolute;
top: 50%;
right: 0;
width: 1px;
height: 200px;
transform: translateY(-50%);
background: linear-gradient(180deg, white, #eeeeee, white);
}
img {
margin-top: 24px;
}
.ellipsis {
width: 160px;
margin-top: 10px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-row {
width: 160px;
margin-top: 10px;
border: 1px solid $theme_color;
line-height: 24px;
display: flex;
text-align: center;
overflow: hidden;
span:nth-child(1) {
color: #fff;
font-size: 16px;
flex: 1;
min-width: 0;
background-color: $theme_color;
}
span:nth-child(2) {
color: #999;
width: 66px;
flex-shrink: 0;
text-decoration: line-through;
}
}
}
}
}
</style>

View File

@@ -1,49 +0,0 @@
.setup-content{
position: relative;
&:hover{
.setup-box{
display: block;
}
}
}
.setup-box{
display: none;
position: absolute;
top: 0;
left: 0;
background: rgba(0, 0, 0, .2);
z-index: 99;
width: 100%;
height: 100%;
.el-button{
float: right;
margin-right: 5px;
margin-top: 5px;
}
}
.modal-tab-bar{
margin-left: 20px;
table{
margin-top: 10px;
display:inline-block;
border: 1px solid #ddd;
border-radius: 5px;
max-height: 400px !important;
overflow: hidden auto;
}
thead{
background-color: #eee;
th{
padding: 5px 0;
}
}
td{
padding: 10px;
text-align: center;
}
tbody>tr:hover{
background-color: aliceblue;
}
}

View File

@@ -0,0 +1,454 @@
<template>
<div class="component-config-panel" v-if="component">
<!-- 1. 容器大小 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandSize = !expandSize">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandSize }"><ArrowRight /></el-icon>
<span class="header-title">容器大小</span>
<el-tooltip content="设置当前组件的排版模式、内外边距及圆角样式" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="collapse-body" v-show="expandSize">
<!-- 排版方式 -->
<div class="form-row">
<span class="row-label">排版宽度</span>
<el-select v-model="compStyle.widthRatio" size="small" class="full-select">
<el-option
v-for="opt in layoutOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</el-select>
</div>
<!-- 可视化嵌套盒模型 (外边距 + 内边距 + 圆角) -->
<div class="box-model-section">
<nested-box-model
:key="props.component?.id || props.component?.key || 'comp'"
:margin="compStyle.margin"
:padding="compStyle.padding"
:radius="compStyle.radius"
:show-radius="true"
@update:margin="val => compStyle.margin = val"
@update:padding="val => compStyle.padding = val"
@update:radius="val => compStyle.radius = val"
/>
</div>
</div>
</div>
<!-- 2. 背景设置 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandBg = !expandBg">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandBg }"><ArrowRight /></el-icon>
<span class="header-title">背景设置</span>
<el-tooltip content="设置当前组件的背景颜色与背景图片" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="collapse-body" v-show="expandBg">
<!-- 背景色 -->
<div class="form-row">
<span class="row-label">背景色</span>
<div class="color-picker-row">
<el-color-picker
v-model="compStyle.backgroundColor"
show-alpha
size="small"
class="color-picker-swatch"
:predefine="predefineColors"
@change="handleColorChange"
@active-change="handleColorActiveChange"
/>
<el-input
v-model="compStyle.backgroundColor"
size="small"
class="color-input"
placeholder="透明"
clearable
@blur="handleInputBlur"
/>
</div>
</div>
<!-- 背景图片 -->
<div class="form-row mt-10">
<span class="row-label">
背景图片
<el-tooltip content="建议大小: 500KB 内; 建议格式: WebP/JPG/PNG" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
<div class="bg-img-btns">
<el-button size="small" @click="handleSelectImage" class="action-btn">
选择图片
</el-button>
<el-button
size="small"
:disabled="!compStyle.backgroundImage"
@click="handleClearImage"
class="action-btn secondary"
>
{{ compStyle.backgroundImage ? '清除背景图' : '未选择背景图' }}
</el-button>
</div>
</div>
<div v-if="compStyle.backgroundImage" class="bg-preview-row">
<img :src="compStyle.backgroundImage" class="bg-preview-img" alt="背景预览" />
</div>
</div>
</div>
</div>
<div v-else class="empty-panel">
请在画布中选中一个组件进行组件配置
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { ArrowRight, QuestionFilled } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import NestedBoxModel from '../controls/NestedBoxModel.vue';
const props = defineProps({
component: {
type: Object,
default: null
}
});
const expandSize = ref(true);
const expandBg = ref(true);
const layoutOptions = computed(() => {
if (['topBar', 'searchHeader', 'footerNav', 'categoryNav'].includes(props.component?.type)) {
return [
{ value: '100%', label: '整行' }
];
}
if (['channelGrid', 'freeLayout', 'promoGroup', 'video', 'goodsList', 'gridFloor', 'goodsGrid', 'goodsCard', 'bannerFloor', 'goodsBar', 'channelFloor', 'recommendFeed', 'couponList', 'titleBar', 'noticeBar'].includes(props.component?.type)) {
return [
{ value: '100%', label: '整行' },
{ value: '66.67%', label: '三分之二' },
{ value: '50%', label: '一半' },
{ value: '33.33%', label: '三分之一' },
{ value: '16.67%', label: '六分之一' }
];
}
if (props.component?.type === 'carousel') {
return [
{ value: '66.67%', label: '三分之二' },
{ value: '100%', label: '整行' },
{ value: '50%', label: '一半' },
{ value: '33.33%', label: '三分之一' },
{ value: '16.67%', label: '六分之一' }
];
}
if (['categoryMegaMenu', 'userBenefits'].includes(props.component?.type)) {
return [
{ value: '16.67%', label: '六分之一' },
{ value: '33.33%', label: '三分之一' },
{ value: '50%', label: '一半' }
];
}
if (['brandWall', 'hotZone', 'articleContent'].includes(props.component?.type)) {
return [
{ value: '100%', label: '整行' },
{ value: '66.67%', label: '三分之二' },
{ value: '50%', label: '一半' },
{ value: '33.33%', label: '三分之一' }
];
}
return [
{ value: '100%', label: '整行' },
{ value: 'auto', label: '自适应' },
{ value: '50%', label: '半行 (50%)' },
{ value: '33.3%', label: '三分之一 (33.3%)' },
{ value: '25%', label: '四分之一 (25%)' }
];
});
const compStyle = computed(() => {
if (!props.component) return {};
if (!props.component.componentStyle) {
props.component.componentStyle = {};
}
const s = props.component.componentStyle;
if (!Array.isArray(s.margin)) s.margin = [0, 0, 0, 0];
if (!Array.isArray(s.padding)) s.padding = [0, 0, 0, 0];
if (!Array.isArray(s.radius)) s.radius = [0, 0, 0, 0];
if (['topBar', 'searchHeader', 'footerNav', 'categoryNav'].includes(props.component?.type)) {
s.widthRatio = '100%';
} else if (!s.widthRatio) {
s.widthRatio = props.component?.type === 'carousel' ? '66.67%' : (['categoryMegaMenu', 'userBenefits'].includes(props.component?.type) ? '16.67%' : '100%');
}
if (['channelGrid', 'freeLayout', 'promoGroup', 'video', 'goodsList', 'gridFloor', 'goodsGrid', 'goodsCard', 'bannerFloor', 'goodsBar', 'channelFloor', 'recommendFeed', 'couponList', 'titleBar', 'noticeBar'].includes(props.component?.type)) {
if (s.widthRatio === '33.3%') s.widthRatio = '33.33%';
else if (s.widthRatio === '66.6%' || s.widthRatio === '66.7%') s.widthRatio = '66.67%';
else if (s.widthRatio === '16.6%' || s.widthRatio === '16.7%') s.widthRatio = '16.67%';
else if (!['100%', '66.67%', '50%', '33.33%', '16.67%'].includes(s.widthRatio)) {
s.widthRatio = '100%';
}
}
if (props.component?.type === 'carousel') {
if (s.widthRatio === '33.3%') s.widthRatio = '33.33%';
else if (s.widthRatio === '66.6%' || s.widthRatio === '66.7%') s.widthRatio = '66.67%';
else if (s.widthRatio === '16.6%' || s.widthRatio === '16.7%') s.widthRatio = '16.67%';
else if (!['66.67%', '100%', '50%', '33.33%', '16.67%'].includes(s.widthRatio)) {
s.widthRatio = '66.67%';
}
}
if (['categoryMegaMenu', 'userBenefits'].includes(props.component?.type)) {
if (s.widthRatio === '33.3%') s.widthRatio = '33.33%';
else if (s.widthRatio === '16.6%' || s.widthRatio === '16.7%') s.widthRatio = '16.67%';
else if (!['16.67%', '33.33%', '50%'].includes(s.widthRatio)) {
s.widthRatio = '16.67%';
}
}
if (['brandWall', 'hotZone', 'articleContent'].includes(props.component?.type)) {
if (s.widthRatio === '33.3%') s.widthRatio = '33.33%';
else if (s.widthRatio === '66.6%' || s.widthRatio === '66.7%') s.widthRatio = '66.67%';
else if (!['100%', '66.67%', '50%', '33.33%'].includes(s.widthRatio)) {
s.widthRatio = '100%';
}
}
if (s.backgroundColor === undefined) {
s.backgroundColor = '';
} else if (s.backgroundColor && /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(s.backgroundColor.trim())) {
s.backgroundColor = s.backgroundColor.trim().replace(/,\s*0\s*\)$/, ', 1)');
}
return s;
});
const predefineColors = [
'#ffffff',
'#f8fafc',
'#f1f5f9',
'#f3f4f6',
'#fee2e2',
'#ffedd5',
'#fef3c7',
'#e0e7ff',
'#1e293b',
'#0f172a'
];
function handleColorChange(val) {
if (!val) return;
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(val)) {
compStyle.value.backgroundColor = val.replace(/,\s*0\s*\)$/, ', 1)');
}
}
function handleColorActiveChange(val) {
if (!val) return;
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(val)) {
compStyle.value.backgroundColor = val.replace(/,\s*0\s*\)$/, ', 1)');
}
}
function handleInputBlur() {
const val = compStyle.value.backgroundColor;
if (!val) return;
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(val.trim())) {
compStyle.value.backgroundColor = val.trim().replace(/,\s*0\s*\)$/, ', 1)');
}
}
const openImagePicker = inject('openImagePicker', null);
function handleSelectImage() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) {
compStyle.value.backgroundImage = url;
}
});
} else {
ElMessageBox.prompt('请输入背景图片 URL 地址:', '选择背景图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: compStyle.value.backgroundImage || '',
inputPattern: /^\S+$/,
inputErrorMessage: '请输入有效的图片 URL 地址'
})
.then(({ value }) => {
compStyle.value.backgroundImage = value;
})
.catch(() => {});
}
}
function handleClearImage() {
compStyle.value.backgroundImage = '';
}
</script>
<style lang="scss" scoped>
.component-config-panel {
padding: 12px;
box-sizing: border-box;
.setting-collapse-card {
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
.collapse-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
cursor: pointer;
user-select: none;
background: #fafafa;
border-bottom: 1px solid transparent;
transition: background-color 0.2s;
&:hover {
background: #f5f7fa;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
.arrow-icon {
font-size: 12px;
color: #909399;
transition: transform 0.2s;
&.is-expanded {
transform: rotate(90deg);
}
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #262626;
}
.help-icon {
font-size: 14px;
color: #909399;
cursor: pointer;
}
}
}
.collapse-body {
padding: 14px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #606266;
display: flex;
align-items: center;
gap: 4px;
.help-icon {
font-size: 13px;
color: #909399;
cursor: pointer;
&:hover {
color: #409eff;
}
}
}
.full-select {
width: 100%;
}
.color-picker-row {
display: flex;
align-items: center;
gap: 8px;
.color-input {
flex: 1;
}
}
.bg-img-btns {
display: flex;
gap: 10px;
.action-btn {
flex: 1;
height: 32px;
&.secondary {
background: #fafafa;
color: #909399;
}
}
}
}
.box-model-section {
margin-top: 14px;
}
.tip-spec-box {
background: #2b2e3b;
color: #f1f5f9;
padding: 8px 10px;
border-radius: 4px;
font-size: 11px;
line-height: 1.5;
margin-bottom: 10px;
word-break: break-all;
}
.bg-preview-row {
margin-top: 10px;
padding: 6px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
text-align: center;
.bg-preview-img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
border-radius: 4px;
}
}
}
}
}
.empty-panel {
text-align: center;
padding: 60px 20px;
color: #909399;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div class="panel-brand-wall">
<div class="panel-section">
<div class="section-title">
<div class="title-with-tip">
<span>内容设置</span>
<el-tooltip
content="设置品牌图片列表、跳转链接与展示排版"
placement="top"
>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="sub-section-title">图片列表</div>
<!-- 图片列表卡片 -->
<div class="brand-cards-list">
<div
v-for="(item, idx) in options.items"
:key="idx"
class="brand-item-card"
>
<!-- 第一行:重新选择 + 缩略图预览 -->
<div class="card-row-top">
<el-button
size="small"
class="reselect-btn"
@click="handleSelectImage(item)"
>
{{ item.img ? '重新选择' : '选择图片' }}
</el-button>
<!-- 右侧图片缩略图 -->
<div
class="thumb-preview-box"
@click="handleSelectImage(item)"
:title="item.img ? '点击更换图片' : '点击上传图片'"
>
<template v-if="item.img">
<img :src="item.img" alt="brand" class="thumb-img" />
<span class="thumb-del-badge" @click.stop="item.img = ''" title="移除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="upload-icon"><Plus /></el-icon>
<span class="upload-text">上传图片</span>
</template>
</div>
</div>
<!-- 第二行:图片标题 + 工具按钮组 -->
<div class="card-row-bottom">
<el-input
v-model="item.title"
size="small"
placeholder="图片标题,可选"
class="title-input"
/>
<div class="action-btn-group">
<el-button
size="small"
class="action-btn"
:class="{ 'is-linked': !!item.url }"
@click="handleSetLink(item)"
@contextmenu.prevent="item.url = ''"
:title="item.url ? (`已设链接: ${item.url} (点击修改,右键清除)`) : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === (options.items || []).length - 1"
@click="moveItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="options.items.splice(idx, 1)"
title="删除图片"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 添加图片大按钮 -->
<el-button
class="add-image-full-btn"
@click="handleAddImage"
>
<el-icon><Plus /></el-icon> 添加图片
</el-button>
<!-- 排版间距与列数 -->
<div class="form-row mt-16">
<span class="row-label">单行列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列</el-radio-button>
<el-radio-button :label="5">5 列</el-radio-button>
<el-radio-button :label="6">6 列</el-radio-button>
</el-radio-group>
</div>
<div class="form-row mt-12">
<span class="row-label">卡片间距(px)</span>
<el-input-number
v-model="options.gap"
:min="0"
:max="40"
:step="2"
size="small"
class="num-input"
/>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Close, Link, Top, Bottom, QuestionFilled } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 初始化默认字段
if (!props.options.columns) props.options.columns = 4;
if (props.options.gap === undefined) props.options.gap = 12;
if (!Array.isArray(props.options.items)) {
props.options.items = [
{ title: '双层收纳 9.9RMB', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?brand=1' },
{ title: '双层收纳 29.9', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?brand=2' },
{ title: '双层收纳 29.9', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?brand=3' },
{ title: '双层收纳 29.9', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?brand=4' }
];
}
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
function handleSelectImage(item) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) item.img = url;
});
} else {
ElMessageBox.prompt('请输入图片 URL 地址:', '上传品牌图', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.img || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
item.img = value.trim();
})
.catch(() => {});
}
}
function handleSetLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
item.url = url;
if (!item.title && title) item.title = title;
});
} else {
ElMessageBox.prompt('请输入跳转链接(如 /goodsList):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
item.url = value.trim();
})
.catch(() => {});
}
}
function moveItem(idx, dir) {
const list = props.options.items;
const target = idx + dir;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
function handleAddImage() {
const nextNum = (props.options.items?.length || 0) + 1;
const iconIdx = ((nextNum - 1) % 4) + 1;
props.options.items.push({
title: `品牌图 ${nextNum}`,
img: `https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_${iconIdx}.png`,
url: '/goodsList'
});
}
</script>
<style lang="scss" scoped>
.panel-brand-wall {
font-size: 13px;
color: #334155;
.panel-section {
padding-bottom: 20px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
.title-with-tip {
display: flex;
align-items: center;
gap: 6px;
.help-icon {
font-size: 14px;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #475569;
}
}
}
}
.sub-section-title {
font-size: 12px;
font-weight: 600;
color: #475569;
margin-bottom: 10px;
}
.brand-cards-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
.brand-item-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s;
&:hover {
border-color: #cbd5e1;
}
.card-row-top {
display: flex;
align-items: center;
gap: 10px;
.reselect-btn {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #1e293b;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
}
}
.thumb-preview-box {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.2s;
&:hover {
border-color: #f97316;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-icon {
font-size: 13px;
color: #94a3b8;
margin-right: 4px;
}
.upload-text {
font-size: 12px;
color: #64748b;
}
.thumb-del-badge {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(15, 23, 42, 0.7);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
}
.card-row-bottom {
display: flex;
align-items: center;
gap: 8px;
.title-input {
flex: 1;
:deep(.el-input__wrapper) {
background: #ffffff;
}
}
.action-btn-group {
display: flex;
align-items: center;
gap: 4px;
.action-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
&:hover:not(:disabled) {
border-color: #cbd5e1;
color: #1e293b;
}
&.is-linked {
border-color: #fdba74;
background: #fff7ed;
color: #ea580c;
}
&.del-btn:hover {
border-color: #fecaca;
background: #fef2f2;
color: #ef4444;
}
}
}
}
}
}
.add-image-full-btn {
width: 100%;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
color: #334155;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
background: #fffaf5;
}
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
&.mt-16 {
margin-top: 16px;
}
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #475569;
}
.num-input {
width: 120px;
}
}
}
}
</style>

View File

@@ -0,0 +1,529 @@
<template>
<div class="panel-carousel">
<!-- 内容设置 -->
<div class="panel-section">
<div class="section-title">
<div class="title-with-tip">
<span>内容设置</span>
<el-tooltip
content="设置轮播播放参数及切换动效"
placement="top"
>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<!-- 1. 播放轮播 -->
<div class="form-row flex-between">
<span class="row-label">播放轮播</span>
<el-switch v-model="options.autoPlay" size="small" />
</div>
<!-- 2. 轮播间隔(ms) -->
<div class="form-row flex-col mt-12">
<span class="row-label">轮播间隔(ms)</span>
<el-input-number
v-model="options.interval"
:min="1000"
:max="15000"
:step="500"
size="small"
controls-position="right"
class="full-num-input"
/>
</div>
<!-- 3. 显示左右切换按钮 -->
<div class="form-row flex-between mt-12">
<span class="row-label">显示左右切换按钮</span>
<el-switch v-model="options.showArrow" size="small" />
</div>
<!-- 4. 切换动效 -->
<div class="form-row flex-col mt-12">
<span class="row-label">切换动效</span>
<el-select v-model="options.effect" size="small" class="full-select">
<el-option label="淡入淡出" value="fade" />
<el-option label="平滑滚动" value="slide" />
<el-option label="卡片式" value="card" />
</el-select>
</div>
<!-- 5. 图片缩放模式 -->
<div class="form-row flex-col mt-12">
<span class="row-label">图片缩放模式</span>
<el-select v-model="options.fitMode" size="small" class="full-select">
<el-option label="等比完整展示 (全画面不裁切)" value="contain" />
<el-option label="等比填满裁剪 (铺满容器)" value="cover" />
</el-select>
</div>
<!-- 6. 高宽比例 -->
<div class="form-row flex-col mt-12">
<span class="row-label">高宽比例 (随排版宽度等比缩放)</span>
<el-select v-model="options.aspectRatio" size="small" class="full-select">
<el-option label="2.5 : 1 (标准电商海报 推荐)" value="2.5:1" />
<el-option label="16 : 9 (影音宽屏海报)" value="16:9" />
<el-option label="2 : 1 (大促通栏横幅)" value="2:1" />
<el-option label="3 : 1 (超宽细长条海报)" value="3:1" />
<el-option label="固定高度模式 (不随宽度等比缩放)" value="custom" />
</el-select>
</div>
<!-- 固定高度输入 (仅在 custom 模式下显示) -->
<div class="form-row flex-col mt-12" v-if="options.aspectRatio === 'custom'">
<span class="row-label">固定高度 (px)</span>
<el-input-number
v-model="options.height"
:min="80"
:max="800"
:step="10"
size="small"
controls-position="right"
class="full-num-input"
/>
</div>
<!-- 7. 显示底部指示点 -->
<div class="form-row flex-between mt-12">
<span class="row-label">显示底部指示点</span>
<el-switch v-model="options.showIndicator" size="small" />
</div>
<!-- 6. 轮播图列表卡片 -->
<div class="sub-section-title mt-16">轮播图</div>
<div class="slides-cards-list">
<div
v-for="(slide, idx) in options.list"
:key="idx"
class="slide-item-card"
>
<!-- 第一行:左侧重新选择按钮,右侧图片预览 -->
<div class="card-row-top">
<el-button
size="small"
class="reselect-btn"
@click="handleSelectImage(slide)"
>
{{ slide.img ? '重新选择' : '选择图片' }}
</el-button>
<!-- 右侧图片缩略图 -->
<div
class="slide-thumb-box"
@click="handleSelectImage(slide)"
:title="slide.img ? '点击更换海报图片' : '点击上传海报图片'"
>
<template v-if="slide.img">
<img :src="slide.img" alt="slide" class="thumb-img" />
<span class="thumb-del-badge" @click.stop="slide.img = ''" title="移除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="upload-icon"><Plus /></el-icon>
<span class="upload-text">上传海报</span>
</template>
</div>
</div>
<!-- 第二行:左侧标题输入,右侧操作工具栏 -->
<div class="card-row-bottom">
<el-input
v-model="slide.title"
size="small"
placeholder="图片标题,可选"
class="title-input"
/>
<div class="action-btn-group">
<el-button
size="small"
class="action-btn"
:class="{ 'is-linked': !!slide.url }"
@click="handleSetLink(slide)"
@contextmenu.prevent="slide.url = ''"
:title="slide.url ? (`已设链接: ${slide.url} (点击修改,右键清除)`) : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveSlide(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === (options.list || []).length - 1"
@click="moveSlide(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="options.list.splice(idx, 1)"
title="删除海报"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 添加图片大按钮 -->
<el-button
class="add-slide-full-btn"
@click="handleAddSlide"
>
<el-icon><Plus /></el-icon> 添加图片
</el-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Close, Link, Top, Bottom, QuestionFilled } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 初始化默认字段
if (props.options.autoPlay === undefined) props.options.autoPlay = true;
if (props.options.interval === undefined) props.options.interval = 4000;
if (props.options.showArrow === undefined) props.options.showArrow = true;
if (props.options.showIndicator === undefined) props.options.showIndicator = true;
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 = 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' },
{ 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' }
];
}
// 注入全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
function handleSelectImage(slide) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) slide.img = url;
});
} else {
ElMessageBox.prompt('请输入海报图片 URL 地址:', '上传轮播图', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: slide.img || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
slide.img = value.trim();
})
.catch(() => {});
}
}
function handleSetLink(slide) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
slide.url = url;
if (!slide.title && title) slide.title = title;
});
} else {
ElMessageBox.prompt('请输入跳转链接(如 /goodsList):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: slide.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
slide.url = value.trim();
})
.catch(() => {});
}
}
function moveSlide(idx, dir) {
const list = props.options.list;
const target = idx + dir;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
function handleAddSlide() {
const nextNum = (props.options.list?.length || 0) + 1;
const iconIdx = ((nextNum - 1) % 4) + 1;
props.options.list.push({
title: `轮播图 ${nextNum}`,
img: `https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_${iconIdx}.png`,
url: '/goodsList'
});
}
</script>
<style lang="scss" scoped>
.panel-carousel {
font-size: 13px;
color: #334155;
.panel-section {
padding-bottom: 20px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
.title-with-tip {
display: flex;
align-items: center;
gap: 6px;
.help-icon {
font-size: 14px;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #475569;
}
}
}
}
.sub-section-title {
font-size: 12px;
font-weight: 600;
color: #475569;
margin-bottom: 10px;
}
.form-row {
display: flex;
&.flex-between {
align-items: center;
justify-content: space-between;
}
&.flex-col {
flex-direction: column;
gap: 6px;
}
&.mt-12 {
margin-top: 12px;
}
&.mt-16 {
margin-top: 16px;
}
.row-label {
font-size: 12px;
color: #475569;
}
.full-num-input, .full-select {
width: 100%;
}
}
.slides-cards-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
.slide-item-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s;
&:hover {
border-color: #cbd5e1;
}
.card-row-top {
display: flex;
align-items: center;
gap: 10px;
.reselect-btn {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #1e293b;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
}
}
.slide-thumb-box {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.2s;
&:hover {
border-color: #f97316;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-icon {
font-size: 13px;
color: #94a3b8;
margin-right: 4px;
}
.upload-text {
font-size: 12px;
color: #64748b;
}
.thumb-del-badge {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(15, 23, 42, 0.7);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
}
.card-row-bottom {
display: flex;
align-items: center;
gap: 8px;
.title-input {
flex: 1;
:deep(.el-input__wrapper) {
background: #ffffff;
}
}
.action-btn-group {
display: flex;
align-items: center;
gap: 4px;
.action-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
&:hover:not(:disabled) {
border-color: #cbd5e1;
color: #1e293b;
}
&.is-linked {
border-color: #fdba74;
background: #fff7ed;
color: #ea580c;
}
&.del-btn:hover {
border-color: #fecaca;
background: #fef2f2;
color: #ef4444;
}
}
}
}
}
}
.add-slide-full-btn {
width: 100%;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
color: #334155;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
background: #fffaf5;
}
}
}
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="panel-category-mega-menu">
<!-- 1. 内容设置 -->
<div class="panel-section">
<div class="section-title">
<div class="title-with-tip">
<span>内容设置</span>
<el-tooltip
content="设置商品分类的文字颜色及显示字号"
placement="top"
>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<!-- 文字颜色 -->
<div class="form-row flex-col">
<span class="row-label">文字颜色</span>
<div class="color-picker-input-group">
<el-color-picker
v-model="options.textColor"
size="small"
class="color-picker"
/>
<el-input
v-model="options.textColor"
size="small"
placeholder="#111927"
class="color-input"
/>
</div>
</div>
<!-- 字号 -->
<div class="form-row flex-col mt-16">
<span class="row-label">字号</span>
<el-input-number
v-model="options.fontSize"
:min="12"
:max="24"
:step="1"
size="small"
controls-position="right"
class="font-size-input"
/>
</div>
<!-- 标题文案 -->
<div class="form-row flex-col mt-16">
<span class="row-label">标题文案</span>
<el-input
v-model="options.title"
size="small"
placeholder="分类"
class="full-input"
/>
</div>
<!-- 悬浮高亮色 -->
<div class="form-row flex-col mt-16">
<span class="row-label">悬浮高亮色</span>
<div class="color-picker-input-group">
<el-color-picker
v-model="options.activeColor"
size="small"
class="color-picker"
/>
<el-input
v-model="options.activeColor"
size="small"
placeholder="#409EFF"
class="color-input"
/>
</div>
</div>
<!-- 数据来源 -->
<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>
</div>
<div class="category-cards-list">
<div
v-for="(cat, idx) in options.categories"
:key="idx"
class="cat-item-card"
>
<div class="cat-card-main">
<el-input
v-model="cat.name"
size="small"
placeholder="分类名称"
class="cat-name-input"
/>
<div class="cat-card-actions">
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveCat(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === (options.categories || []).length - 1"
@click="moveCat(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="options.categories.splice(idx, 1)"
title="删除分类"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<el-button
class="add-cat-btn"
@click="handleAddCategory"
>
<el-icon><Plus /></el-icon> 添加分类条目
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { QuestionFilled, Plus, Delete, Top, Bottom } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 初始化默认字段
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;
if (!props.options.activeColor) props.options.activeColor = '#ea580c';
if (!Array.isArray(props.options.categories)) {
props.options.categories = [
{
name: '精选频道',
subCategories: [
{ name: '每日精选', items: ['新品尝鲜', '大牌特惠', '超级秒杀', '今日热卖'] },
{ name: '特色专区', items: ['企业团购', '会员专享', '积分兑换', '清仓特卖'] }
]
},
{
name: '新品上架',
subCategories: [
{ name: '本周新发', items: ['数码新品', '秋季服饰', '美妆尝新', '家居收纳'] },
{ name: '潮流榜单', items: ['人气推荐', '编辑精选', '预售抢先'] }
]
},
{
name: '企业采购',
subCategories: [
{ name: '办公采购', items: ['办公用纸', '文具收纳', '商用打印', '会议茶水'] },
{ name: '员工福利', items: ['节日礼盒', '数码卡券', '健康体检', '员工关怀'] }
]
},
{
name: '领券中心',
subCategories: [
{ name: '平台券', items: ['全场满减', '品类专享', '运费券', '会员特权'] },
{ name: '大牌券', items: ['数码大额券', '生鲜补贴', '美妆立减', '母婴满减'] }
]
}
];
}
function handleAddCategory() {
const nextNum = props.options.categories.length + 1;
props.options.categories.push({
name: `新分类 ${nextNum}`,
subCategories: [
{ name: '热门分类', items: ['精选推荐', '热销爆款', '品质优选'] }
]
});
}
function moveCat(idx, dir) {
const list = props.options.categories;
const target = idx + dir;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
</script>
<style lang="scss" scoped>
.panel-category-mega-menu {
font-size: 13px;
color: #334155;
.panel-section {
padding-bottom: 20px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 14px;
.title-with-tip {
display: flex;
align-items: center;
gap: 6px;
.help-icon {
font-size: 14px;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #475569;
}
}
}
}
.form-row {
display: flex;
&.flex-col {
flex-direction: column;
gap: 8px;
}
&.mt-16 {
margin-top: 16px;
}
&.mt-20 {
margin-top: 20px;
}
.row-label {
font-size: 12px;
font-weight: 500;
color: #475569;
}
.color-picker-input-group {
display: flex;
align-items: center;
gap: 8px;
.color-picker {
flex-shrink: 0;
}
.color-input {
flex: 1;
}
}
.font-size-input {
width: 100%;
}
.full-input {
width: 100%;
}
}
.categories-sub-section {
border-top: 1px dashed #e2e8f0;
padding-top: 14px;
.sub-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.sub-title {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.sub-count {
font-size: 12px;
color: #94a3b8;
}
}
.category-cards-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
.cat-item-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 8px;
.cat-card-main {
display: flex;
align-items: center;
gap: 8px;
.cat-name-input {
flex: 1;
}
.cat-card-actions {
display: flex;
align-items: center;
gap: 4px;
.action-btn {
width: 26px;
height: 26px;
padding: 0;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
&:hover:not(:disabled) {
border-color: #cbd5e1;
color: #1e293b;
}
&.del-btn:hover {
border-color: #fecaca;
background: #fef2f2;
color: #ef4444;
}
}
}
}
}
}
.add-cat-btn {
width: 100%;
height: 32px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #ffffff;
color: #475569;
font-size: 12px;
transition: all 0.2s;
&:hover {
border-color: #ea580c;
color: #ea580c;
background: #fffaf5;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,534 @@
<template>
<div class="panel-category-nav">
<!-- 1. 内容设置 -->
<div class="panel-section">
<div class="section-title">内容设置</div>
<!-- 显示全部分类开关 -->
<div class="form-row flex-between">
<span class="row-label">显示全部分类</span>
<el-switch v-model="options.showAllCategory" size="small" />
</div>
<!-- 全部分类文案 -->
<div class="form-row mt-12" v-if="options.showAllCategory !== false">
<span class="row-label">全部分类文案</span>
<el-input
v-model="options.allCategoryText"
size="small"
placeholder="全部分类"
class="flex-1"
/>
</div>
<!-- 全部分类图标 -->
<div class="form-row mt-12" v-if="options.showAllCategory !== false">
<span class="row-label">全部分类图标</span>
<div class="all-cat-icon-ctrl">
<div class="icon-thumb-box" v-if="options.allCategoryIcon" @click="handleSelectAllCategoryIcon" title="更换图标">
<img :src="options.allCategoryIcon" alt="全部分类图标" class="thumb-img" />
<el-icon class="thumb-del" @click.stop="options.allCategoryIcon = ''" title="删除"><Close /></el-icon>
</div>
<el-button size="small" class="action-btn" @click="handleSelectAllCategoryIcon">
{{ options.allCategoryIcon ? '修改图标' : '选择图标' }}
</el-button>
<el-button
size="small"
link
type="danger"
v-if="options.allCategoryIcon"
@click="options.allCategoryIcon = ''"
>
清除
</el-button>
</div>
</div>
</div>
<!-- 2. 导航项列表 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>导航项</span>
<span class="items-count">共 {{ (options.navItems || []).length }} 项</span>
</div>
<div class="nav-cards-list">
<div
v-for="(item, idx) in options.navItems"
:key="idx"
class="nav-item-card"
>
<!-- 卡片上半部分:左侧选择/标题,右侧图标缩略图 -->
<div class="card-body">
<div class="left-col">
<el-button
size="small"
class="reselect-btn"
@click="handleSelectCategory(item)"
>
{{ item.title ? '重新选择' : '选择分类' }}
</el-button>
<el-input
v-model="item.title"
size="small"
placeholder="分类名称"
class="title-input"
/>
</div>
<!-- 右侧图标上传框 -->
<div
class="right-thumb-col"
@click="handleSelectIcon(item)"
:title="item.img ? '点击更换分类图标' : '点击上传分类图标'"
>
<template v-if="item.img">
<img :src="item.img" alt="分类图标" class="cat-thumb" />
<span class="thumb-del-badge" @click.stop="item.img = ''" title="移除图标">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<el-icon class="upload-plus"><Plus /></el-icon>
<span class="upload-tip">图标</span>
</template>
</div>
</div>
<!-- 卡片底栏:操作按钮组 (链接、上移、下移、删除) -->
<div class="card-actions">
<el-button
size="small"
class="action-btn"
:type="item.url ? 'primary' : 'default'"
:plain="!item.url"
@click="handleSetLink(item)"
@contextmenu.prevent="item.url = ''"
:title="item.url ? (`已设链接: ${item.url} (点击修改,右键清除)`) : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === options.navItems.length - 1"
@click="moveItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="options.navItems.splice(idx, 1)"
title="删除导航项"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 添加导航项大按钮 -->
<el-button
type="primary"
plain
class="add-item-full-btn"
@click="handleAddItem"
>
<el-icon><Plus /></el-icon> 添加导航项
</el-button>
</div>
<!-- 3. 文字与悬浮颜色设置 -->
<div class="panel-section">
<div class="section-title">文字与悬浮颜色</div>
<div class="form-row">
<span class="row-label">常态文字颜色</span>
<el-color-picker v-model="options.textColor" size="small" />
</div>
<div class="form-row mt-12">
<span class="row-label">悬浮/选中高亮色</span>
<el-color-picker v-model="options.activeColor" size="small" />
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Close, Link, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 注入全局选择器
const openLinkPicker = inject('openLinkPicker', null);
const openImagePicker = inject('openImagePicker', null);
// 1. 重新选择分类
function handleSelectCategory(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title, raw }) => {
if (title) item.title = title;
if (url) item.url = url;
if (raw && (raw.image || raw.icon || raw.pic)) {
item.img = raw.image || raw.icon || raw.pic;
}
});
} else {
ElMessageBox.prompt('请输入分类名称:', '设置分类', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.title || '',
inputPattern: /\S+/,
inputErrorMessage: '分类名称不能为空'
})
.then(({ value }) => {
item.title = value.trim();
})
.catch(() => {});
}
}
// 2. 选择分类图标图片
function handleSelectIcon(item) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) item.img = url;
});
} else {
ElMessageBox.prompt('请输入图标图片 URL 地址:', '上传图标', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.img || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
item.img = value.trim();
})
.catch(() => {});
}
}
// 3. 选择全部分类图标
function handleSelectAllCategoryIcon() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) props.options.allCategoryIcon = url;
});
} else {
ElMessageBox.prompt('请输入图标图片 URL 地址:', '设置全部分类图标', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.allCategoryIcon || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.allCategoryIcon = value.trim();
})
.catch(() => {});
}
}
// 4. 设置链接
function handleSetLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
item.url = url;
if (!item.title && title) item.title = title;
});
} else {
ElMessageBox.prompt('请输入跳转链接路径(留空清除链接):', `设置 [${item.title || '导航项'}] 链接`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '',
inputPattern: /^.*$/
})
.then(({ value }) => {
item.url = (value || '').trim();
})
.catch(() => {});
}
}
// 5. 上移/下移
function moveItem(idx, direction) {
const target = idx + direction;
if (!Array.isArray(props.options.navItems)) return;
if (target < 0 || target >= props.options.navItems.length) return;
const moved = props.options.navItems.splice(idx, 1)[0];
props.options.navItems.splice(target, 0, moved);
}
// 6. 添加导航项
function handleAddItem() {
if (!Array.isArray(props.options.navItems)) props.options.navItems = [];
const count = props.options.navItems.length + 1;
props.options.navItems.push({
title: `新分类${count}`,
img: '',
url: `/goodsList?categoryId=${count}`,
badge: ''
});
}
</script>
<style lang="scss" scoped>
.panel-category-nav {
padding: 12px;
box-sizing: border-box;
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.items-count {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
&.flex-between {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #475569;
}
.flex-1 {
flex: 1;
}
.all-cat-icon-ctrl {
display: flex;
align-items: center;
gap: 8px;
.icon-thumb-box {
position: relative;
width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid #e2e8f0;
overflow: hidden;
cursor: pointer;
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-del {
position: absolute;
top: 1px;
right: 1px;
font-size: 10px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
padding: 1px;
}
}
}
}
.nav-cards-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
.nav-item-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.card-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
.left-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
.reselect-btn {
align-self: flex-start;
font-size: 12px;
color: #334155;
border-color: #cbd5e1;
padding: 0 10px;
height: 26px;
&:hover {
color: #2563eb;
border-color: #93c5fd;
}
}
.title-input {
width: 100%;
}
}
.right-thumb-col {
width: 60px;
height: 60px;
border-radius: 6px;
border: 1px dashed #cbd5e1;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
flex-shrink: 0;
overflow: hidden;
transition: border-color 0.15s, background-color 0.15s;
&:hover {
border-color: #93c5fd;
background-color: #f0f7ff;
}
.cat-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-del-badge {
position: absolute;
top: 3px;
right: 3px;
width: 16px;
height: 16px;
background: rgba(15, 23, 42, 0.65);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: background 0.15s;
&:hover {
background: #e11d48;
}
}
.upload-plus {
font-size: 16px;
color: #94a3b8;
margin-bottom: 2px;
}
.upload-tip {
font-size: 10px;
color: #94a3b8;
}
}
}
.card-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
padding-top: 8px;
border-top: 1px dashed #f1f5f9;
.action-btn {
padding: 0 8px;
height: 26px;
&.del-btn {
color: #ef4444;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
}
}
}
}
}
.add-item-full-btn {
width: 100%;
height: 34px;
font-size: 12px;
border-dashed: true;
border: 1px dashed #93c5fd;
background: #f8fafc;
&:hover {
background: #eff6ff;
border-color: #3b82f6;
}
}
}
}
</style>

View File

@@ -0,0 +1,780 @@
<template>
<div class="panel-channel-floor">
<!-- 1. 标题区设置 -->
<div class="panel-section">
<div class="section-title">标题区设置</div>
<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="如:场景频道" clearable />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<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) -->
<div class="panel-section">
<div class="section-title">场景主视觉</div>
<!-- 主视觉图片 -->
<div class="form-item">
<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 mt-10">
<span class="item-label">主视觉大标题</span>
<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="如:精选场景好物,一站购齐" clearable />
</div>
<div class="form-item">
<span class="item-label">行动按钮文案</span>
<el-input v-model="options.heroButtonText" size="small" placeholder="如:进入频道" clearable />
</div>
<!-- 按钮跳转链接 -->
<div class="form-item">
<span class="item-label">按钮跳转链接</span>
<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 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>
<el-radio-button label="plain">简洁浅色</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 3. 活动入口 (Promos) -->
<div class="panel-section">
<div class="section-title flex-between">
<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" 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-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-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>
<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 flex-row">
<span class="item-label">展示商品数量</span>
<el-input-number v-model="options.goodsCount" :min="1" :max="8" size="small" controls-position="right" />
</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="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</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 type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import GoodsDialog from '@/views/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: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
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 = '主视觉 + 商品组合楼层';
if (!props.options.heroImage) props.options.heroImage = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
if (!props.options.heroTitle) props.options.heroTitle = '品质生活馆';
if (!props.options.heroSubtitle) props.options.heroSubtitle = '精选场景好物,一站购齐';
if (!props.options.heroButtonText) props.options.heroButtonText = '进入频道';
if (!props.options.heroHref) props.options.heroHref = '/goods';
if (!props.options.heroStyle) props.options.heroStyle = 'gradient';
if (!props.options.promos) {
props.options.promos = [
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时', url: '/goods' },
{ title: '新品驾到', desc: '官方正品首发', tag: '新品', url: '/goods' }
];
}
if (!props.options.goodsCount) props.options.goodsCount = 6;
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: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.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({
title: '热销特惠',
desc: '全品类甄选底价',
tag: '热卖',
url: '/goods'
});
}
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);
}
// 5. 选择活动入口链接
function handleSelectPromoLink(p) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
if (url) p.url = url;
if (!p.title && title) p.title = title;
});
} else {
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>
<style lang="scss" scoped>
.panel-channel-floor {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
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;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
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;
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: 10px;
.promo-editor-card {
background: #f8fafc;
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: 8px;
}
}
}
.empty-tip {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 16px 0;
}
.goods-list-preview {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,509 @@
<template>
<div class="panel-channel-grid">
<!-- 1. 内容设置 -->
<div class="panel-section">
<div class="section-title">
<div class="title-with-tip">
<span>内容设置</span>
<el-tooltip
content="设置频道宫格入口图文、跳转链接及排版间距"
placement="top"
>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="sub-section-title">频道入口</div>
<!-- 频道卡片列表 -->
<div class="channel-cards-list">
<div
v-for="(item, idx) in options.items"
:key="idx"
class="channel-item-card"
>
<!-- 第一行:左侧【选择资源】,右侧【替换图片/缩略图】 -->
<div class="card-row-top">
<el-button
size="small"
class="select-resource-btn"
@click="handleSelectResource(item)"
>
选择资源
</el-button>
<!-- 右侧图片上传/预览框 -->
<div
class="thumb-picker-box"
@click="handleSelectImage(item)"
:title="item.img ? '点击更换图片' : '点击上传图片'"
>
<template v-if="item.img">
<img :src="item.img" alt="频道图" class="thumb-img" />
<span class="thumb-del-badge" @click.stop="item.img = ''" title="移除图片">
<el-icon><Close /></el-icon>
</span>
</template>
<template v-else>
<span class="upload-tip-text">替换图片</span>
</template>
</div>
</div>
<!-- 第二行:主标题与副标题 -->
<div class="card-row-inputs">
<el-input
v-model="item.title"
size="small"
placeholder="主标题(如:企业采购)"
class="title-input"
/>
<el-input
v-model="item.subtitle"
size="small"
placeholder="副标题(如:批量省心)"
class="subtitle-input"
/>
</div>
<!-- 第三行:底栏工具条 (链接、上移、下移、删除) -->
<div class="card-row-actions">
<el-button
size="small"
class="action-btn link-btn"
:class="{ 'is-linked': !!item.url }"
@click="handleSetLink(item)"
@contextmenu.prevent="item.url = ''"
:title="item.url ? (`已设链接: ${item.url} (点击修改,右键清除)`) : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === (options.items || []).length - 1"
@click="moveItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="options.items.splice(idx, 1)"
title="删除频道"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 添加频道按钮 -->
<el-button
class="add-channel-full-btn"
@click="handleAddChannel"
>
<el-icon><Plus /></el-icon> 添加频道
</el-button>
<!-- 排版间距与圆角参数 -->
<div class="form-row mt-16">
<span class="row-label">图片圆角</span>
<el-input-number
v-model="options.imageRadius"
:min="0"
:max="50"
:step="1"
size="small"
class="num-input"
/>
</div>
<div class="form-row mt-12">
<span class="row-label">间距</span>
<el-input-number
v-model="options.gap"
:min="0"
:max="40"
:step="1"
size="small"
class="num-input"
/>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Close, Link, Top, Bottom, QuestionFilled } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 注入全局选择器
const openLinkPicker = inject('openLinkPicker', null);
const openImagePicker = inject('openImagePicker', null);
// 1. 选择资源 (打开链接/资源树选择器,回填名称与链接)
function handleSelectResource(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title, raw }) => {
if (title && !item.title) item.title = title;
if (url) item.url = url;
if (raw && (raw.image || raw.icon || raw.pic) && !item.img) {
item.img = raw.image || raw.icon || raw.pic;
}
});
} else {
ElMessageBox.prompt('请输入频道名称:', '选择资源', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.title || '',
inputPattern: /\S+/,
inputErrorMessage: '名称不能为空'
})
.then(({ value }) => {
item.title = value.trim();
})
.catch(() => {});
}
}
// 2. 选择图片
function handleSelectImage(item) {
if (openImagePicker) {
openImagePicker((url) => {
if (url) item.img = url;
});
} else {
ElMessageBox.prompt('请输入图片 URL 地址:', '上传频道图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.img || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
item.img = value.trim();
})
.catch(() => {});
}
}
// 3. 设置链接
function handleSetLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
item.url = url;
if (!item.title && title) item.title = title;
});
} else {
ElMessageBox.prompt('请输入跳转链接(如 /goodsList?channel=1):', '设置跳转链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '',
inputPattern: /^\S+$/,
inputErrorMessage: '链接不能为空'
})
.then(({ value }) => {
item.url = value.trim();
})
.catch(() => {});
}
}
// 4. 上移 / 下移
function moveItem(idx, dir) {
const list = props.options.items;
const target = idx + dir;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
// 5. 添加频道
function handleAddChannel() {
if (!Array.isArray(props.options.items)) {
props.options.items = [];
}
const nextNum = props.options.items.length + 1;
const iconIdx = ((nextNum - 1) % 4) + 1;
props.options.items.push({
title: `频道 ${nextNum}`,
subtitle: '品质好物',
img: `https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/channel-grid/channel_${iconIdx}.png`,
url: `/goodsList?channel=${nextNum}`
});
}
</script>
<style lang="scss" scoped>
.panel-channel-grid {
font-size: 13px;
color: #334155;
.panel-section {
padding-bottom: 20px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
.title-with-tip {
display: flex;
align-items: center;
gap: 6px;
.help-icon {
font-size: 14px;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #475569;
}
}
}
}
.sub-section-title {
font-size: 12px;
font-weight: 600;
color: #475569;
margin-bottom: 10px;
}
.channel-cards-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
.channel-item-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s;
&:hover {
border-color: #cbd5e1;
}
// 第一行:选择资源 + 替换图片
.card-row-top {
display: flex;
align-items: center;
gap: 10px;
.select-resource-btn {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #1e293b;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
}
}
.thumb-picker-box {
flex: 1;
height: 38px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
&:hover {
border-color: #f97316;
.upload-tip-text {
color: #ea580c;
}
}
.upload-tip-text {
font-size: 12px;
color: #64748b;
user-select: none;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.thumb-del-badge {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(15, 23, 42, 0.7);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
&:hover {
background: #ef4444;
}
}
}
}
// 第二行:主副标题输入
.card-row-inputs {
display: flex;
align-items: center;
gap: 8px;
.title-input, .subtitle-input {
flex: 1;
:deep(.el-input__wrapper) {
background: #ffffff;
box-shadow: 0 0 0 1px #e2e8f0 inset;
&:hover {
box-shadow: 0 0 0 1px #cbd5e1 inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #f97316 inset;
}
}
}
}
// 第三行:工具条
.card-row-actions {
display: flex;
align-items: center;
gap: 6px;
.action-btn {
flex: 1;
height: 28px;
padding: 0;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
&:hover:not(:disabled) {
border-color: #cbd5e1;
color: #1e293b;
}
&.link-btn.is-linked {
border-color: #fdba74;
background: #fff7ed;
color: #ea580c;
}
&.del-btn {
&:hover {
border-color: #fecaca;
background: #fef2f2;
color: #ef4444;
}
}
}
}
}
}
// 添加频道按钮
.add-channel-full-btn {
width: 100%;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
color: #334155;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
border-color: #f97316;
color: #ea580c;
background: #fffaf5;
}
}
// 参数设置行
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
&.mt-16 {
margin-top: 16px;
}
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #475569;
}
.num-input {
width: 120px;
}
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="panel-free-layout">
<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="100" :max="1000" :step="20" size="small" controls-position="right" />
</div>
<div class="layout-hint">
当前自由布局容器内已包含 {{ options.elements?.length || 0 }} 个绝对定位元素。
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
</script>
<style lang="scss" scoped>
.panel-free-layout {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
.item-label {
font-size: 12px;
color: #475569;
}
}
.layout-hint {
margin-top: 10px;
font-size: 12px;
color: #64748b;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,639 @@
<template>
<div class="panel-goods-grid">
<!-- 1. 标题与更多设置 -->
<div class="panel-section">
<div class="section-title">宫格标题设置</div>
<div class="form-item">
<span class="item-label">主标题</span>
<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天爆卖好物精选" clearable />
</div>
<div class="form-item flex-row">
<span class="item-label">展示查看更多</span>
<el-switch v-model="options.showMoreLink" size="small" />
</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 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>
<el-radio-button :label="5">5 列 (推荐)</el-radio-button>
<el-radio-button :label="6">6 列</el-radio-button>
</el-radio-group>
</div>
<div class="form-item flex-row">
<span class="item-label">矩阵实线网格分割线</span>
<el-switch v-model="options.showBorder" size="small" />
</div>
</div>
<!-- 3. 商品列表与管理 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>宫格商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<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>
<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">
<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>
<!-- 兜底商品选择弹窗(当未注入全局 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="fallbackModal = false">取消</el-button>
<el-button type="primary" @click="confirmFallbackSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
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 '@/views/lili-dialog/goods-dialog.vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
// 注入来自 pc/index.vue 的全局选择器
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
const openGoodsPicker = inject('openGoodsPicker', null);
const fallbackModal = ref(false);
const tempSelectedGoods = ref([]);
// 1. 选择更多链接
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(() => {});
}
}
// 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>
<style lang="scss" scoped>
.panel-goods-grid {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
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;
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: 380px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.grid-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
transition: background 0.15s ease;
&:hover {
background: #f1f5f9;
}
.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;
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;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.g-name-input {
:deep(.el-input__inner) {
font-size: 12px;
padding: 0 6px;
height: 24px;
}
}
.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;
}
}
}
}
.goods-empty-tip {
padding: 18px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #94a3b8;
font-size: 12px;
background: #f8fafc;
.empty-btn-row {
display: flex;
gap: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,736 @@
<template>
<div class="panel-grid-floor">
<!-- 1. 楼层标题设置 (Floor Header) -->
<div class="panel-section">
<div class="section-title">楼层大标题设置</div>
<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;" clearable />
</div>
<div class="form-item flex-1">
<span class="item-label">楼层名称</span>
<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" 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>
<!-- 2. 右侧子分类快捷标签 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>右侧分类标签</span>
<el-button link type="primary" size="small" @click="handleAddTag">
<el-icon><Plus /></el-icon> 添加标签
</el-button>
</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="分类名称" clearable />
<el-button link type="danger" size="small" @click="options.subCategories.splice(tIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 3. 左侧专属营销竖版海报 / 横幅 (横幅楼层 bannerFloor 与宫格楼层通用) -->
<div class="panel-section">
<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">海报横幅图片</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 mt-10">
<span class="item-label">海报主标题</span>
<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 元" clearable />
</div>
<div class="form-item">
<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>
<!-- 4. 右侧商品列数与排版 -->
<div class="panel-section">
<div class="section-title">右侧商品排版</div>
<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>
<el-radio-button :label="4">4 列 (推荐)</el-radio-button>
<el-radio-button :label="5">5 列</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 5. 楼层商品选择 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>楼层商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<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">
<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" :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>
</el-button>
</div>
</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 type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import GoodsDialog from '@/views/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: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
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;
if (!props.options.floorNum) props.options.floorNum = '1F';
if (!props.options.title) props.options.title = '潮流数码';
if (!props.options.subtitle) props.options.subtitle = 'DIGITAL & TECH';
if (!props.options.subCategories) {
props.options.subCategories = ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
}
// 兼容 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;
if (!props.options.goods) {
props.options.goods = [
{ id: '101', 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: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.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('新标签');
}
// 1. 选择更多链接
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(() => {});
}
}
// 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>
<style lang="scss" scoped>
.panel-grid-floor {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
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;
font-weight: 500;
}
}
.form-row-2 {
display: flex;
gap: 8px;
}
.tags-editor-wrap {
display: flex;
flex-direction: column;
gap: 6px;
.tag-edit-row {
display: flex;
align-items: center;
gap: 6px;
}
}
}
.mr-4 {
margin-right: 4px;
}
.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: 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: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
.thumb-wrap-mini {
position: relative;
width: 38px;
height: 38px;
border-radius: 4px;
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;
display: flex;
flex-direction: column;
gap: 2px;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,627 @@
<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>
<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-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" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon>
绘制热区
</el-button>
<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>
<div class="header-action-group">
<el-button type="primary" plain size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 绘制热区
</el-button>
<el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区
</el-button>
</div>
</div>
<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-item"
>
<!-- 热区卡片头部 -->
<div class="zone-header">
<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-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 class="mt-8">
<el-button type="primary" size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 立即绘制热区
</el-button>
</div>
</div>
</div>
<!-- 绘制热区弹窗(复用移动端热区绘制器) -->
<hotzone ref="hotzoneDialogRef" width="960px" @changeZone="handleChangeZone" />
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom, Aim } from '@element-plus/icons-vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import hotzone from '@/views/shop/hotzone';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 注入来自 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: `热区 ${count + 1}`,
url: '/goodsList',
top: Math.min(10 + count * 5, 60),
left: Math.min(10 + (count % 3) * 30, 70),
width: 25,
height: 35
});
}
const hotzoneDialogRef = ref(null);
// 绘制热区弹窗打开
function handleOpenHotzone() {
const imgUrl = props.options.imageUrl || '';
if (!imgUrl) {
ElMessage.warning('请先输入或选择热区底图');
return;
}
const zoneInfo = (props.options.zones || []).map((z, idx) => {
const leftPer = z.leftPer !== undefined ? z.leftPer : (Number(z.left) || 0) / 100;
const topPer = z.topPer !== undefined ? z.topPer : (Number(z.top) || 0) / 100;
const widthPer = z.widthPer !== undefined ? z.widthPer : (Number(z.width) || 20) / 100;
const heightPer = z.heightPer !== undefined ? z.heightPer : (Number(z.height) || 20) / 100;
const link = z.url || z.link || '';
return {
...z,
leftPer,
topPer,
widthPer,
heightPer,
link,
title: z.title || `热区 ${idx + 1}`,
type: z.type || 'other'
};
});
if (hotzoneDialogRef.value) {
hotzoneDialogRef.value.open({
img: imgUrl,
zoneInfo
});
}
}
// 热区绘制器变更回调
function handleChangeZone(zoneList) {
if (!Array.isArray(zoneList)) return;
props.options.zones = zoneList.map((z, idx) => {
const leftVal = z.leftPer !== undefined ? z.leftPer * 100 : (z.left !== undefined ? z.left : 0);
const topVal = z.topPer !== undefined ? z.topPer * 100 : (z.top !== undefined ? z.top : 0);
const widthVal = z.widthPer !== undefined ? z.widthPer * 100 : (z.width !== undefined ? z.width : 20);
const heightVal = z.heightPer !== undefined ? z.heightPer * 100 : (z.height !== undefined ? z.height : 20);
const linkVal = z.link || z.url || '';
const titleVal = z.title || `热区 ${idx + 1}`;
return {
...z,
title: titleVal,
url: linkVal,
link: linkVal,
left: Math.round(leftVal * 10) / 10,
top: Math.round(topVal * 10) / 10,
width: Math.round(widthVal * 10) / 10,
height: Math.round(heightVal * 10) / 10,
type: z.type || 'other'
};
});
}
</script>
<style lang="scss" scoped>
.panel-hot-zone {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
.item-label {
font-size: 12px;
color: #475569;
}
}
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
.action-picker-btn {
flex-shrink: 0;
padding: 0 10px;
}
}
.hotzone-preview-box {
margin-top: 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
overflow: hidden;
background: #f8fafc;
.img-preview {
width: 100%;
max-height: 140px;
object-fit: cover;
display: block;
}
.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;
align-items: center;
gap: 6px;
font-size: 12px;
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-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

@@ -0,0 +1,521 @@
<template>
<div class="panel-notice-bar">
<!-- 1. 基础配置与图标设置 -->
<div class="panel-section">
<div class="section-title">快讯标语与图标</div>
<div class="form-item">
<span class="item-label">前缀标语</span>
<el-input v-model="options.title" size="small" placeholder="如:公告快讯 / 今日热点" clearable />
</div>
<!-- 徽标图标 / 图片选择器 -->
<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 mt-10">
<span class="item-label">“更多”文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:更多" clearable />
</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="如:/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. 快讯条目列表 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>快讯条目 ({{ itemsList?.length || 0 }} 条)</span>
<el-button link type="primary" size="small" @click="handleAddItem">
<el-icon><Plus /></el-icon> 添加快讯
</el-button>
</div>
<div class="notice-items-container">
<div
v-for="(item, idx) in itemsList"
:key="idx"
class="notice-card-item"
>
<div class="item-top-row">
<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="通知" />
<el-option label="热议" value="热议" />
</el-select>
<el-input v-model="item.time" size="small" placeholder="时间(如 10-28)" style="width: 100px" />
<div class="item-actions">
<el-button
link
size="small"
:disabled="idx === 0"
@click="moveItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="idx === itemsList.length - 1"
@click="moveItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="itemsList.splice(idx, 1)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="item-title-row mt-6">
<el-input v-model="item.title" size="small" placeholder="快讯内容标题" clearable />
</div>
<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>
</div>
</div>
</template>
<script setup>
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: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
// 全局选择器注入
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 = [
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
];
} else if (!props.options.items && props.options.notices) {
props.options.items = props.options.notices.map(n => ({
tag: n.tag || '最新',
title: n.title || n.text || '',
time: n.time || '10-28',
url: n.url || '/article'
}));
}
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: '最新',
title: '新发布商城系统通知公告',
time: '刚刚',
url: '/article'
});
}
function moveItem(index, step) {
const target = index + step;
if (target < 0 || target >= props.options.items.length) return;
const temp = props.options.items[index];
props.options.items[index] = props.options.items[target];
props.options.items[target] = temp;
}
</script>
<style lang="scss" scoped>
.panel-notice-bar {
padding: 8px 4px;
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 5px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.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;
}
.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;
}
}
}
.notice-items-container {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 420px;
overflow-y: auto;
.notice-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
.item-top-row {
display: flex;
align-items: center;
gap: 8px;
.item-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="panel-promo-group">
<!-- 1. 排版列数 -->
<div class="panel-section">
<div class="section-title">排版列数</div>
<div class="form-item">
<span class="item-label">卡片列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="2">2 列</el-radio-button>
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 2. 活动卡片列表 -->
<div class="panel-section">
<div class="section-title flex-between">
<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" 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 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">
<!-- 标题行 -->
<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 { 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: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 注入来自 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: '/goodsList'
});
}
</script>
<style lang="scss" scoped>
.panel-promo-group {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
.item-label {
font-size: 12px;
color: #475569;
}
}
}
.cards-list {
display: flex;
flex-direction: column;
gap: 14px;
.promo-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);
}
.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;
font-weight: 600;
color: #334155;
}
.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

@@ -0,0 +1,284 @@
<template>
<div class="panel-recommend-feed">
<!-- 1. 标题设置 -->
<div class="panel-section">
<div class="section-title">标题设置</div>
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:猜你喜欢" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:真实推荐商品流" />
</div>
</div>
<!-- 2. 布局样式 -->
<div class="panel-section">
<div class="section-title">布局样式</div>
<div class="form-item">
<span class="item-label">展现形态</span>
<el-radio-group v-model="options.layoutStyle" size="small">
<el-radio-button label="featured">精选大卡+网格 (推荐)</el-radio-button>
<el-radio-button label="grid">标准推荐网格</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<span class="item-label">背景色</span>
<el-color-picker v-model="options.backgroundColor" size="small" />
</div>
</div>
<!-- 3. 加载与数量控制 -->
<div class="panel-section">
<div class="section-title">推荐流加载设置</div>
<div class="form-item">
<span class="item-label">首屏/每页数量</span>
<el-input-number v-model="options.pageSize" :min="4" :max="20" size="small" controls-position="right" />
</div>
<div class="form-item">
<span class="item-label">最多加载页数</span>
<el-input-number v-model="options.maxPages" :min="1" :max="10" size="small" controls-position="right" />
</div>
</div>
<!-- 4. 推荐商品池管理 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>推荐商品池 ({{ options.goods?.length || 0 }} 件)</span>
<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">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="goods-item-row">
<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" :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)">
<el-icon><Delete /></el-icon>
</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>
<!-- 兜底商品选择弹窗(当未注入全局 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 type="primary" @click="confirmGoodsSelected">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import GoodsDialog from '@/views/lili-dialog/goods-dialog.vue';
import { Delete } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
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 = '真实推荐商品流';
if (!props.options.layoutStyle) props.options.layoutStyle = 'featured';
if (!props.options.backgroundColor) props.options.backgroundColor = '#f8fafc';
if (!props.options.pageSize) props.options.pageSize = 8;
if (!props.options.maxPages) props.options.maxPages = 3;
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' }
];
}
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 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>
<style lang="scss" scoped>
.panel-recommend-feed {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-group {
display: flex;
align-items: center;
gap: 8px;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.goods-list-preview {
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #f1f5f9;
.idx-tag {
font-size: 11px;
font-weight: 700;
color: #64748b;
background: #e2e8f0;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
.goods-empty-tip {
padding: 16px;
text-align: center;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #94a3b8;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
}
</style>

View File

@@ -0,0 +1,410 @@
<template>
<div class="panel-title-bar">
<div class="panel-section">
<div class="section-title">标题内容设置</div>
<div class="form-item">
<span class="item-label">主标题文案</span>
<el-input v-model="options.mainTitle" size="small" placeholder="如:限时特卖" clearable />
</div>
<!-- 主标题前缀图标 / 图片 -->
<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="如:精选爆款 每日早十点上新" clearable />
</div>
<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>
<el-radio-button label="center">居中对齐</el-radio-button>
</el-radio-group>
</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">
<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 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,
required: true
},
options: {
type: Object,
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;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.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;
}
}
}
}
</style>

View File

@@ -0,0 +1,473 @@
<template>
<div class="panel-video">
<!-- 1. 媒体资源设置 -->
<div class="panel-section">
<div class="section-title">视频源与媒体文件</div>
<!-- 视频文件选择 -->
<div class="form-item">
<span class="item-label">视频文件 URL (.mp4)</span>
<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">跳转链接 (选填,点击视频或行动点跳转)</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">显示播放控制条</span>
<el-switch v-model="options.controls" size="small" />
</div>
<div class="form-item flex-row">
<span class="item-label">自动静音播放</span>
<el-switch v-model="options.autoPlay" size="small" />
</div>
<div class="form-item flex-row">
<span class="item-label">循环播放</span>
<el-switch v-model="options.loop" size="small" />
</div>
</div>
</div>
</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,
required: true
},
options: {
type: Object,
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>
.panel-video {
.panel-section {
margin-bottom: 20px;
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;
color: #0f172a;
margin-bottom: 12px;
}
.mt-14 {
margin-top: 14px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.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

@@ -0,0 +1,369 @@
<template>
<div class="row-config-panel" v-if="row">
<!-- 1. 行位置 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandOrder = !expandOrder">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandOrder }"><ArrowRight /></el-icon>
<span class="header-title">行位置</span>
<el-tooltip content="调整当前行在整个页面中的上下层级与排序" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="collapse-body" v-show="expandOrder">
<!-- 上行:第 X 行 / 共 Y 行 + 置顶、置底 -->
<div class="row-order-top">
<div class="order-text-wrap">
<div class="main-row-idx">第 {{ rowIndex + 1 }} 行</div>
<div class="total-row-idx">共 {{ totalRows }} 行</div>
</div>
<div class="order-btns">
<el-button
size="small"
class="pos-btn"
:disabled="isFirst"
@click="emit('move-top')"
>
置顶
</el-button>
<el-button
size="small"
class="pos-btn"
:disabled="isLast"
@click="emit('move-bottom')"
>
置底
</el-button>
</div>
</div>
<!-- 下行:行切换下拉选择框 -->
<div class="row-select-wrap">
<el-select
:model-value="rowIndex"
@update:model-value="val => emit('move-to-row', val)"
size="small"
class="full-select"
>
<el-option
v-for="(_, i) in totalRows"
:key="i"
:value="i"
:label="`第 ${i + 1} 行`"
/>
</el-select>
</div>
</div>
</div>
<!-- 2. 盒模型 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandBox = !expandBox">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandBox }"><ArrowRight /></el-icon>
<span class="header-title">盒模型</span>
<el-tooltip content="设置当前行的宽度、高度、间距、内外边距与圆角" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</div>
<div class="collapse-body" v-show="expandBox">
<!-- 3 列尺寸输入:宽度、高度、行内间距 -->
<div class="box-dimension-grid">
<div class="dimension-item">
<span class="dim-label">宽度</span>
<input
type="text"
v-model="row.rowStyle.width"
class="dim-input"
placeholder="1920"
/>
</div>
<div class="dimension-item">
<span class="dim-label">高度</span>
<input
type="text"
v-model="row.rowStyle.height"
class="dim-input"
placeholder="默认"
/>
</div>
<div class="dimension-item">
<span class="dim-label">行内间距</span>
<input
type="text"
v-model="row.rowStyle.gap"
class="dim-input"
placeholder="0"
/>
</div>
</div>
<!-- 可视化嵌套盒模型 (外边距 + 内边距 + 圆角) -->
<div class="box-model-section">
<nested-box-model
:key="props.row?.id || props.row?.key || 'row'"
:margin="row.rowStyle.margin"
:padding="row.rowStyle.padding"
:radius="row.rowStyle.radius"
:show-radius="true"
@update:margin="val => row.rowStyle.margin = val"
@update:padding="val => row.rowStyle.padding = val"
@update:radius="val => row.rowStyle.radius = val"
/>
</div>
</div>
</div>
</div>
<div v-else class="empty-panel">
请在画布中选中一行以进行行配置
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ArrowRight, QuestionFilled } from '@element-plus/icons-vue';
import NestedBoxModel from '../controls/NestedBoxModel.vue';
const props = defineProps({
row: {
type: Object,
default: null
},
rowIndex: {
type: Number,
default: 0
},
totalRows: {
type: Number,
default: 0
},
isFirst: {
type: Boolean,
default: false
},
isLast: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'move-up',
'move-down',
'move-top',
'move-bottom',
'move-to-row',
'delete-row'
]);
const expandOrder = ref(true);
const expandBox = ref(true);
</script>
<style lang="scss" scoped>
.row-config-panel {
padding: 12px;
box-sizing: border-box;
.setting-collapse-card {
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
.collapse-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
cursor: pointer;
user-select: none;
background: #fafafa;
border-bottom: 1px solid transparent;
transition: background-color 0.2s;
&:hover {
background: #f5f7fa;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
.arrow-icon {
font-size: 12px;
color: #909399;
transition: transform 0.2s;
&.is-expanded {
transform: rotate(90deg);
}
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #262626;
}
.help-icon {
font-size: 14px;
color: #909399;
cursor: pointer;
}
}
}
.collapse-body {
padding: 14px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
/* 1. 行位置 */
.row-order-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.order-text-wrap {
display: flex;
flex-direction: column;
.main-row-idx {
font-size: 16px;
font-weight: 700;
color: #1f2937;
}
.total-row-idx {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
}
.order-btns {
display: flex;
gap: 8px;
.pos-btn {
padding: 0 14px;
height: 28px;
border-radius: 4px;
font-size: 12px;
color: #4b5563;
background: #ffffff;
border: 1px solid #d1d5db;
&:hover {
color: #ff6a00;
border-color: #ff6a00;
}
&:disabled {
color: #9ca3af;
border-color: #e5e7eb;
background: #f9fafb;
}
}
}
}
.row-select-wrap {
width: 100%;
.full-select {
width: 100%;
}
}
/* 2. 盒模型 3 列尺寸 */
.box-dimension-grid {
display: flex;
gap: 10px;
margin-bottom: 14px;
.dimension-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
.dim-label {
font-size: 12px;
color: #4b5563;
}
.dim-input {
width: 100%;
height: 28px;
line-height: 26px;
background: #ffffff;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 12px;
color: #303133;
box-sizing: border-box;
outline: none;
transition: all 0.2s;
&:focus {
border-color: #ff6a00;
box-shadow: 0 0 0 2px rgba(255, 106, 0, 0.1);
}
}
}
}
.box-model-section {
margin-top: 10px;
}
/* 3. 背景设置 */
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #606266;
display: flex;
align-items: center;
gap: 4px;
.help-icon {
font-size: 13px;
color: #909399;
cursor: pointer;
&:hover {
color: #409eff;
}
}
}
}
}
}
}
.empty-panel {
text-align: center;
padding: 60px 20px;
color: #909399;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,692 @@
<template>
<div class="pc-config-panel-container">
<!-- 顶部选中状态卡片 (匹配魔方 dark navy card) -->
<div class="selected-header-card">
<div class="header-info">
<div class="selected-tag">当前选中</div>
<div class="selected-name">{{ currentSelectedTitle }}</div>
</div>
<div class="header-icon-box">
<el-icon :size="24">
<component :is="currentSelectedIcon" />
</el-icon>
</div>
</div>
<!-- 顶栏三 Tab 切换 -->
<div class="panel-header-tabs">
<div
class="tab-btn"
:class="{ active: activeTab === 'row' }"
@click="setTab('row')"
>
行配置
</div>
<div
class="tab-btn"
:class="{ active: activeTab === 'component' }"
@click="setTab('component')"
>
组件配置
</div>
<div
class="tab-btn"
:class="{ active: activeTab === 'business' }"
@click="setTab('business')"
>
业务配置
</div>
</div>
<div class="panel-scroll-content">
<!-- 1. 行配置 -->
<div v-show="activeTab === 'row'">
<row-config-panel
:row="selectedRow"
:row-index="selectedRowIndex"
:total-rows="totalRows"
:is-first="selectedRowIndex === 0"
:is-last="selectedRowIndex === totalRows - 1"
@move-up="emit('move-row-up')"
@move-down="emit('move-row-down')"
@move-top="emit('move-row-top')"
@move-bottom="emit('move-row-bottom')"
@move-to-row="idx => emit('move-to-row', idx)"
@delete-row="emit('delete-row')"
/>
</div>
<!-- 2. 组件配置 -->
<div v-show="activeTab === 'component'">
<component-config-panel
:component="selectedComponent"
/>
</div>
<!-- 3. 业务配置 -->
<div v-show="activeTab === 'business'">
<div v-if="!selectedComponent" class="empty-panel">
请在画布中选中一个组件进行业务内容配置
</div>
<div v-else class="business-wrapper">
<!-- 动态分发业务配置面板 -->
<component
:is="businessPanelComponent"
v-if="businessPanelComponent"
:component="selectedComponent"
:options="selectedComponent.options"
/>
<div v-else class="fallback-business">
<div class="panel-title">{{ selectedComponent.name || selectedComponent.type }} 内容设置</div>
<p class="panel-desc">当前组件的基础属性与参数设置:</p>
<div class="form-group" v-for="(val, key) in selectedComponent.options" :key="key">
<span class="prop-key">{{ key }}</span>
<el-input
v-if="typeof val === 'string'"
v-model="selectedComponent.options[key]"
size="small"
/>
<el-switch
v-else-if="typeof val === 'boolean'"
v-model="selectedComponent.options[key]"
/>
<el-input-number
v-else-if="typeof val === 'number'"
v-model="selectedComponent.options[key]"
size="small"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
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';
import { COMPONENT_DEFINITIONS } from '../config.js';
// 导入业务面板
import PanelCategoryNav from './PanelCategoryNav.vue';
import PanelChannelGrid from './PanelChannelGrid.vue';
import PanelCategoryMegaMenu from './PanelCategoryMegaMenu.vue';
import PanelCarousel from './PanelCarousel.vue';
import PanelBrandWall from './PanelBrandWall.vue';
import PanelHotZone from './PanelHotZone.vue';
import PanelFreeLayout from './PanelFreeLayout.vue';
import PanelPromoGroup from './PanelPromoGroup.vue';
import PanelVideo from './PanelVideo.vue';
import PanelGoods from './PanelGoods.vue';
import PanelGridFloor from './PanelGridFloor.vue';
import PanelGoodsGrid from './PanelGoodsGrid.vue';
import PanelChannelFloor from './PanelChannelFloor.vue';
import PanelRecommendFeed from './PanelRecommendFeed.vue';
import PanelTitle from './PanelTitle.vue';
import PanelNotice from './PanelNotice.vue';
const props = defineProps({
selectedRow: {
type: Object,
default: null
},
selectedRowIndex: {
type: Number,
default: 0
},
totalRows: {
type: Number,
default: 0
},
selectedComponent: {
type: Object,
default: null
}
});
const emit = defineEmits([
'move-row-up',
'move-row-down',
'move-row-top',
'move-row-bottom',
'move-to-row',
'delete-row',
'tab-change'
]);
// 默认组件配置
const activeTab = ref('component');
function setTab(tab) {
activeTab.value = tab;
emit('tab-change', tab);
}
function selectRowTab() {
activeTab.value = 'row';
emit('tab-change', 'row');
}
function selectComponentTab() {
activeTab.value = 'component';
emit('tab-change', 'component');
}
defineExpose({
selectComponentTab,
selectRowTab,
activeTab
});
const currentSelectedTitle = computed(() => {
if (props.selectedComponent) {
return props.selectedComponent.name || props.selectedComponent.type;
}
if (props.selectedRow) {
return `第 ${props.selectedRowIndex + 1} 行`;
}
return '未选择项目';
});
const currentSelectedIcon = computed(() => {
if (props.selectedComponent) {
const iconName = props.selectedComponent.icon || COMPONENT_DEFINITIONS.find(d => d.type === props.selectedComponent.type)?.icon;
return iconMap[iconName] || Grid;
}
return Rank;
});
const panelMap = {
// 结构 (3)
// 导航 (3)
categoryNav: PanelCategoryNav,
channelGrid: PanelChannelGrid,
categoryMegaMenu: PanelCategoryMegaMenu,
// 广告 (6)
carousel: PanelCarousel,
brandWall: PanelBrandWall,
hotZone: PanelHotZone,
freeLayout: PanelFreeLayout,
promoGroup: PanelPromoGroup,
video: PanelVideo,
// 商品 (8)
goodsList: PanelGoods,
gridFloor: PanelGridFloor,
goodsGrid: PanelGoodsGrid,
goodsCard: PanelGoods,
bannerFloor: PanelGridFloor,
goodsBar: PanelGoods,
channelFloor: PanelChannelFloor,
recommendFeed: PanelRecommendFeed,
// 营销 (1)
// 内容 (3)
titleBar: PanelTitle,
noticeBar: PanelNotice
};
const businessPanelComponent = computed(() => {
if (!props.selectedComponent) return null;
return panelMap[props.selectedComponent.type] || null;
});
</script>
<style lang="scss" scoped>
.pc-config-panel-container {
display: flex;
flex-direction: column;
height: 100%;
background: #ffffff;
/* 顶部暗蓝选中卡片 */
.selected-header-card {
display: flex;
justify-content: space-between;
align-items: center;
background: #1f2d48;
margin: 12px 12px 4px 12px;
padding: 12px 16px;
border-radius: 8px;
box-sizing: border-box;
.header-info {
display: flex;
flex-direction: column;
.selected-tag {
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 4px;
}
.selected-name {
font-size: 16px;
font-weight: 700;
color: #ffffff;
}
}
.header-icon-box {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
}
.panel-header-tabs {
display: flex;
border-bottom: 1px solid #f0f0f0;
background: #ffffff;
margin: 0 12px;
.tab-btn {
flex: 1;
text-align: center;
padding: 12px 0;
font-size: 13px;
font-weight: 500;
color: #595959;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
&:hover {
color: #ff6a00;
}
&.active {
color: #ff6a00;
font-weight: 600;
border-bottom-color: #ff6a00;
}
}
}
.panel-scroll-content {
flex: 1;
overflow-y: auto;
}
}
.empty-panel {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
font-size: 13px;
}
.business-wrapper {
padding: 12px;
.fallback-business {
.panel-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin-bottom: 4px;
}
.panel-desc {
font-size: 12px;
color: #64748b;
margin-bottom: 16px;
}
.form-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.prop-key {
font-size: 12px;
color: #334155;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
<template>
<div class="box-model-field">
<div class="field-header">
<span class="field-title">{{ label }}</span>
<el-tooltip :content="linked ? '点击解除四向联动' : '点击开启四向联动'" placement="top">
<button
type="button"
class="link-btn"
:class="{ active: linked }"
@click="toggleLinked"
>
<el-icon :size="14">
<Link v-if="linked" />
<Paperclip v-else />
</el-icon>
<span class="link-text">{{ linked ? '已联动' : '独立' }}</span>
</button>
</el-tooltip>
</div>
<!-- 4-directional inputs -->
<div class="field-inputs">
<div
v-for="dir in directions"
:key="dir.key"
class="input-item"
:class="dir.key"
>
<span class="dir-tag">{{ dir.label }}</span>
<el-input-number
:model-value="values[dir.index]"
:min="min"
:max="max"
:step="step"
size="small"
controls-position="right"
class="num-input"
@update:model-value="val => onValueChange(dir.index, val)"
/>
<span class="unit">px</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Link, Paperclip } from '@element-plus/icons-vue';
const props = defineProps({
label: {
type: String,
default: '边距设置'
},
modelValue: {
type: Array,
default: () => [0, 0, 0, 0]
},
mode: {
type: String,
default: 'box' // 'box' (上下左右) or 'corner' (左上、右上、右下、左下)
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 500
},
step: {
type: Number,
default: 1
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// Check if initial 4 values are all equal
const isAllEqual = arr => {
if (!Array.isArray(arr) || arr.length < 4) return true;
return arr[0] === arr[1] && arr[1] === arr[2] && arr[2] === arr[3];
};
const linked = ref(false);
const values = computed(() => {
const v = props.modelValue;
if (!Array.isArray(v) || v.length < 4) {
return [0, 0, 0, 0];
}
return v.map(n => (typeof n === 'number' && !isNaN(n) ? n : 0));
});
const directions = computed(() => {
if (props.mode === 'corner') {
return [
{ key: 'top-left', label: '左上', index: 0 },
{ key: 'top-right', label: '右上', index: 1 },
{ key: 'bottom-right', label: '右下', index: 2 },
{ key: 'bottom-left', label: '左下', index: 3 }
];
}
return [
{ key: 'top', label: '上', index: 0 },
{ key: 'right', label: '右', index: 1 },
{ key: 'bottom', label: '下', index: 2 },
{ key: 'left', label: '左', index: 3 }
];
});
function toggleLinked() {
linked.value = !linked.value;
if (linked.value) {
// When linking, sync all to the first value
const firstVal = values.value[0];
const newArr = [firstVal, firstVal, firstVal, firstVal];
emit('update:modelValue', newArr);
emit('change', newArr);
}
}
function onValueChange(index, val) {
const num = typeof val === 'number' && !isNaN(val) ? val : 0;
let newArr;
if (linked.value) {
newArr = [num, num, num, num];
} else {
newArr = [...values.value];
newArr[index] = num;
}
emit('update:modelValue', newArr);
emit('change', newArr);
}
</script>
<style lang="scss" scoped>
.box-model-field {
margin-bottom: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.field-title {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.link-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
&.active {
background: #eff6ff;
border-color: #3b82f6;
color: #2563eb;
font-weight: 500;
}
}
}
.field-inputs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.input-item {
display: flex;
align-items: center;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 3px 6px;
gap: 4px;
.dir-tag {
font-size: 11px;
color: #94a3b8;
min-width: 22px;
}
.num-input {
flex: 1;
width: 100%;
:deep(.el-input__wrapper) {
box-shadow: none !important;
padding: 0 4px;
background: transparent;
}
:deep(.el-input__inner) {
text-align: center;
font-size: 12px;
color: #1e293b;
}
}
.unit {
font-size: 11px;
color: #94a3b8;
}
}
}
}
</style>

View File

@@ -0,0 +1,524 @@
<template>
<div class="nested-box-model-control">
<!-- 外层: 外边距 (Margin) -->
<div class="outer-box-wrapper">
<div class="box-header-row">
<span class="box-tag tag-orange">外边距</span>
<div class="link-toggle">
<span class="link-label">联动</span>
<el-switch v-model="marginLinked" size="small" />
</div>
</div>
<!-- 外边距 4 向输入框 -->
<input
type="text"
v-model="localMargin[0]"
@input="onMarginChange(0, $event.target.value)"
class="num-input pos-top"
title="外边距-上"
/>
<input
type="text"
v-model="localMargin[1]"
@input="onMarginChange(1, $event.target.value)"
class="num-input pos-right"
title="外边距-右"
/>
<input
type="text"
v-model="localMargin[2]"
@input="onMarginChange(2, $event.target.value)"
class="num-input pos-bottom"
title="外边距-下"
/>
<input
type="text"
v-model="localMargin[3]"
@input="onMarginChange(3, $event.target.value)"
class="num-input pos-left"
title="外边距-左"
/>
<!-- 内层: 内边距 (Padding) -->
<div class="inner-box-wrapper">
<div class="box-header-row">
<span class="box-tag tag-green">内边距</span>
<div class="link-toggle">
<span class="link-label">联动</span>
<el-switch v-model="paddingLinked" size="small" />
</div>
</div>
<!-- 内边距 4 向输入框 -->
<input
type="text"
v-model="localPadding[0]"
@input="onPaddingChange(0, $event.target.value)"
class="num-input pos-top"
title="内边距-上"
/>
<input
type="text"
v-model="localPadding[1]"
@input="onPaddingChange(1, $event.target.value)"
class="num-input pos-right"
title="内边距-右"
/>
<input
type="text"
v-model="localPadding[2]"
@input="onPaddingChange(2, $event.target.value)"
class="num-input pos-bottom"
title="内边距-下"
/>
<input
type="text"
v-model="localPadding[3]"
@input="onPaddingChange(3, $event.target.value)"
class="num-input pos-left"
title="内边距-左"
/>
<!-- 中心参考方块 -->
<div class="box-center-core"></div>
</div>
</div>
<!-- 圆角 border-radius -->
<div class="radius-control-card" v-if="showRadius">
<div class="radius-header">
<span class="radius-title">圆角 border-radius</span>
<div class="link-toggle">
<span class="link-label">联动</span>
<el-switch v-model="radiusLinked" size="small" />
</div>
</div>
<div class="radius-grid">
<!-- 左列: 左上、左下 -->
<div class="radius-col">
<div class="radius-item">
<span class="item-label">左上</span>
<input
type="text"
v-model="localRadius[0]"
@input="onRadiusChange(0, $event.target.value)"
class="num-input"
/>
</div>
<div class="radius-item">
<span class="item-label">左下</span>
<input
type="text"
v-model="localRadius[3]"
@input="onRadiusChange(3, $event.target.value)"
class="num-input"
/>
</div>
</div>
<!-- 中间预览方块 -->
<div class="radius-preview-wrap">
<div class="radius-preview-box" :style="radiusPreviewStyle"></div>
</div>
<!-- 右列: 右上、右下 -->
<div class="radius-col">
<div class="radius-item">
<span class="item-label">右上</span>
<input
type="text"
v-model="localRadius[1]"
@input="onRadiusChange(1, $event.target.value)"
class="num-input"
/>
</div>
<div class="radius-item">
<span class="item-label">右下</span>
<input
type="text"
v-model="localRadius[2]"
@input="onRadiusChange(2, $event.target.value)"
class="num-input"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
const props = defineProps({
margin: {
type: Array,
default: () => [0, 0, 0, 0]
},
padding: {
type: Array,
default: () => [0, 0, 0, 0]
},
radius: {
type: Array,
default: () => [0, 0, 0, 0]
},
showRadius: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:margin', 'update:padding', 'update:radius', 'change']);
// 联动开关:默认关闭
const marginLinked = ref(false);
const paddingLinked = ref(false);
const radiusLinked = ref(false);
watch(marginLinked, val => {
if (val) {
const v = localMargin.value[0] || 0;
localMargin.value = [v, v, v, v];
emit('update:margin', [...localMargin.value]);
emit('change', { type: 'margin', value: [...localMargin.value] });
}
});
watch(paddingLinked, val => {
if (val) {
const v = localPadding.value[0] || 0;
localPadding.value = [v, v, v, v];
emit('update:padding', [...localPadding.value]);
emit('change', { type: 'padding', value: [...localPadding.value] });
}
});
watch(radiusLinked, val => {
if (val) {
const v = localRadius.value[0] || 0;
localRadius.value = [v, v, v, v];
emit('update:radius', [...localRadius.value]);
emit('change', { type: 'radius', value: [...localRadius.value] });
}
});
const localMargin = ref([...(props.margin || [0, 0, 0, 0])]);
const localPadding = ref([...(props.padding || [0, 0, 0, 0])]);
const localRadius = ref([...(props.radius || [0, 0, 0, 0])]);
watch(
() => props.margin,
val => {
if (Array.isArray(val) && val.length >= 4) {
localMargin.value = [...val];
}
},
{ deep: true }
);
watch(
() => props.padding,
val => {
if (Array.isArray(val) && val.length >= 4) {
localPadding.value = [...val];
}
},
{ deep: true }
);
watch(
() => props.radius,
val => {
if (Array.isArray(val) && val.length >= 4) {
localRadius.value = [...val];
}
},
{ deep: true }
);
function parseNum(val) {
const n = parseInt(val, 10);
return isNaN(n) ? 0 : Math.max(0, n);
}
function onMarginChange(index, rawVal) {
const num = parseNum(rawVal);
if (marginLinked.value) {
localMargin.value = [num, num, num, num];
} else {
localMargin.value[index] = num;
}
emit('update:margin', [...localMargin.value]);
emit('change', { type: 'margin', value: [...localMargin.value] });
}
function onPaddingChange(index, rawVal) {
const num = parseNum(rawVal);
if (paddingLinked.value) {
localPadding.value = [num, num, num, num];
} else {
localPadding.value[index] = num;
}
emit('update:padding', [...localPadding.value]);
emit('change', { type: 'padding', value: [...localPadding.value] });
}
function onRadiusChange(index, rawVal) {
const num = parseNum(rawVal);
if (radiusLinked.value) {
localRadius.value = [num, num, num, num];
} else {
localRadius.value[index] = num;
}
emit('update:radius', [...localRadius.value]);
emit('change', { type: 'radius', value: [...localRadius.value] });
}
const radiusPreviewStyle = computed(() => {
const r = localRadius.value;
return {
borderTopLeftRadius: `${r[0] || 0}px`,
borderTopRightRadius: `${r[1] || 0}px`,
borderBottomRightRadius: `${r[2] || 0}px`,
borderBottomLeftRadius: `${r[3] || 0}px`
};
});
</script>
<style lang="scss" scoped>
.nested-box-model-control {
width: 100%;
box-sizing: border-box;
.num-input {
width: 42px;
height: 24px;
line-height: 22px;
background: #ffffff;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 12px;
color: #303133;
outline: none;
box-sizing: border-box;
padding: 0;
transition: all 0.2s;
&:focus {
border-color: #ff6a00;
box-shadow: 0 0 0 2px rgba(255, 106, 0, 0.1);
}
}
.box-header-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 2px;
z-index: 2;
.box-tag {
font-size: 12px;
font-weight: 600;
&.tag-orange {
color: #ff6a00;
}
&.tag-green {
color: #52c41a;
}
}
.link-toggle {
display: flex;
align-items: center;
gap: 6px;
.link-label {
font-size: 12px;
color: #606266;
}
}
}
/* 外层容器: 外边距 */
.outer-box-wrapper {
position: relative;
background: #fffaf5;
border: 1px dashed #ffd8bf;
border-radius: 8px;
padding: 8px 10px 10px;
box-sizing: border-box;
width: 100%;
min-height: 190px;
.pos-top {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
}
.pos-bottom {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
}
.pos-left {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 3;
}
.pos-right {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 3;
}
}
/* 内层容器: 内边距 */
.inner-box-wrapper {
position: relative;
background: #f6ffed;
border: 1px dashed #b7eb8f;
border-radius: 6px;
margin: 28px 40px;
padding: 6px 8px 8px;
box-sizing: border-box;
min-height: 110px;
.pos-top {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 4;
}
.pos-bottom {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 4;
}
.pos-left {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
z-index: 4;
}
.pos-right {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
z-index: 4;
}
.box-center-core {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 1px dashed #91caff;
background: #e6f4ff;
border-radius: 4px;
}
}
/* 圆角卡片 */
.radius-control-card {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
margin-top: 12px;
background: #ffffff;
box-sizing: border-box;
.radius-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.radius-title {
font-size: 12px;
font-weight: 600;
color: #303133;
}
.link-toggle {
display: flex;
align-items: center;
gap: 6px;
.link-label {
font-size: 12px;
color: #606266;
}
}
}
.radius-grid {
display: flex;
justify-content: space-between;
align-items: center;
.radius-col {
display: flex;
flex-direction: column;
gap: 10px;
.radius-item {
display: flex;
align-items: center;
gap: 6px;
.item-label {
font-size: 12px;
color: #909399;
width: 26px;
}
}
}
.radius-preview-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
.radius-preview-box {
width: 44px;
height: 44px;
border: 2px solid #409eff;
background: #fdfdfd;
box-sizing: border-box;
transition: border-radius 0.2s;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
<template>
<div class="tpl-brand-wall" :style="customStyle">
<div
class="brand-grid"
:style="{
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<div
v-for="(item, idx) in brandList"
:key="idx"
class="brand-box"
@click="handleClickItem(item)"
>
<img
v-if="item.img"
:src="item.img"
:alt="item.title || 'brand'"
class="brand-img"
/>
<div v-else class="brand-placeholder">
<span>{{ item.title || item.name || '品牌' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const brandList = computed(() => {
if (Array.isArray(props.options.items) && props.options.items.length > 0) {
return props.options.items;
}
return [
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
];
});
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 4;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 3);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
function handleClickItem(item) {
if (item && item.url) {
console.log('Navigate to:', item.url);
}
}
</script>
<style lang="scss" scoped>
.tpl-brand-wall {
width: 100%;
user-select: none;
box-sizing: border-box;
.brand-grid {
display: grid;
width: 100%;
.brand-box {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 2 / 1;
width: 100%;
height: auto;
min-height: 80px;
max-height: 180px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
cursor: pointer;
box-sizing: border-box;
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.brand-img {
transform: scale(1.03);
}
}
.brand-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.25s ease;
}
.brand-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
color: #64748b;
font-size: 14px;
font-weight: 500;
}
}
}
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<div
class="tpl-carousel"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
>
<div class="carousel-container">
<!-- 轮播图主屏 -->
<transition name="carousel-fade" mode="out-in">
<div :key="currentIdx" class="slide-view">
<img
:src="currentSlide.img"
:alt="currentSlide.title || 'banner'"
class="slide-img"
/>
</div>
</transition>
<!-- 左右切换箭头按钮 -->
<template v-if="options.showArrow !== false && slidesList.length > 1">
<div class="arrow-btn prev-btn" @click.stop="prevSlide">
<el-icon><ArrowLeft /></el-icon>
</div>
<div class="arrow-btn next-btn" @click.stop="nextSlide">
<el-icon><ArrowRight /></el-icon>
</div>
</template>
<!-- 底部指示点 -->
<div
v-if="options.showIndicator !== false && slidesList.length > 1"
class="carousel-indicators"
>
<span
v-for="(_, idx) in slidesList"
:key="idx"
class="indicator-dot"
:class="{ active: currentIdx === idx }"
@click.stop="currentIdx = idx"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const currentIdx = ref(0);
let timer = null;
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
const slidesList = computed(() => {
if (Array.isArray(props.options.list) && props.options.list.length > 0) {
return props.options.list;
}
if (Array.isArray(props.options.banners) && props.options.banners.length > 0) {
return props.options.banners.map(b => ({
title: b.title,
img: b.image || b.img || b.url,
url: b.url || b.link
}));
}
return [
{ 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' }
];
});
const currentSlide = computed(() => {
return slidesList.value[currentIdx.value] || slidesList.value[0];
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const fit = props.options.fitMode || 'cover';
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
const styleObj = {
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 && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
'--carousel-fit': fit,
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
if (props.options.aspectRatio === 'custom' && props.options.height) {
styleObj.height = `${props.options.height}px`;
styleObj.minHeight = `${props.options.height}px`;
}
return styleObj;
});
function prevSlide() {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value - 1 + len) % len;
resetTimer();
}
function nextSlide() {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value + 1) % len;
resetTimer();
}
function startTimer() {
stopTimer();
if (props.options.autoPlay !== false && slidesList.value.length > 1) {
const interval = Number(props.options.interval) || 4000;
timer = setInterval(() => {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value + 1) % len;
}, interval);
}
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function resetTimer() {
stopTimer();
startTimer();
}
watch(
() => [props.options.autoPlay, props.options.interval, slidesList.value.length],
() => {
startTimer();
}
);
onMounted(() => {
startTimer();
});
onBeforeUnmount(() => {
stopTimer();
});
</script>
<style lang="scss" scoped>
.tpl-carousel {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
user-select: none;
background: #ffffff;
border: 1px solid #e2e8f0;
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;
justify-content: center;
.slide-view {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
.slide-img {
width: 100%;
height: 100%;
object-fit: var(--carousel-fit, cover);
display: block;
}
}
.arrow-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
z-index: 10;
transition: all 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.65);
transform: translateY(-50%) scale(1.08);
}
&.prev-btn {
left: 12px;
}
&.next-btn {
right: 12px;
}
}
.carousel-indicators {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
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: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.25s;
&.active {
width: 16px;
border-radius: 3px;
background: #409EFF;
}
&:hover:not(.active) {
background: #ffffff;
}
}
}
}
&.is-mini {
.arrow-btn {
width: 24px;
height: 24px;
font-size: 13px;
}
.carousel-indicators {
bottom: 8px;
gap: 4px;
.indicator-dot {
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

@@ -0,0 +1,443 @@
<template>
<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>
</div>
<!-- 垂直一级类目列表 -->
<div class="category-list">
<div
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 { ref, computed, onMounted } from 'vue';
import { ArrowRight } from '@element-plus/icons-vue';
import { getGoodsCategoryAll } from '@/api/goods.js';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const defaultCategories = [
{
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 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() {
getGoodsCategoryAll(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 && 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',
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
});
</script>
<style lang="scss" scoped>
.tpl-category-mega-menu {
width: 100%;
height: 100%;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
position: relative;
overflow: visible;
&.is-hovering {
z-index: 1050;
}
.menu-header {
height: 42px;
padding: 0 16px;
display: flex;
align-items: center;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
.header-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.5px;
}
}
.category-list {
flex: 1;
display: flex;
flex-direction: column;
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: 6px 16px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
.cat-name {
font-size: var(--font-size, 13px);
color: var(--text-color, #334155);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arrow-icon {
font-size: 11px;
color: #94a3b8;
transition: transform 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
&:hover,
&.is-active {
background: #ecf5ff;
.cat-name {
color: var(--active-color, #409EFF);
font-weight: 600;
}
.arrow-icon {
color: var(--active-color, #409EFF);
transform: translateX(2px);
}
}
}
}
.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

@@ -0,0 +1,205 @@
<template>
<div class="tpl-category-nav" :style="customStyle">
<div class="nav-container">
<!-- 1. 全部分类入口 (当开启显示全部分类时) -->
<div
v-if="options.showAllCategory"
class="all-category-btn"
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
>
<img
v-if="options.allCategoryIcon"
:src="options.allCategoryIcon"
class="all-icon-img"
alt="icon"
/>
<el-icon v-else class="all-icon-svg"><Menu /></el-icon>
<span class="all-text">{{ options.allCategoryText || '全部分类' }}</span>
<el-icon class="all-arrow"><ArrowDown /></el-icon>
</div>
<!-- 2. 分类导航项水平列表 -->
<div class="nav-items-wrapper">
<div
v-for="(item, idx) in options.navItems"
:key="idx"
class="cat-item"
:class="{ 'is-first': idx === 0 }"
@click="handleClickItem(item)"
>
<img
v-if="item.img"
:src="item.img"
class="cat-icon"
alt="cat-icon"
/>
<span
class="cat-title"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#e11d48'
}"
>
{{ item.title }}
</span>
<span v-if="item.badge" class="cat-badge" :style="{ backgroundColor: options.activeColor || '#e11d48' }">
{{ item.badge }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Menu, ArrowDown } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
height: `${s.height || 42}px`
};
});
function handleClickItem(item) {
if (item && item.url) {
console.log('Navigate to:', item.url);
}
}
</script>
<style lang="scss" scoped>
.tpl-category-nav {
width: 100%;
border-bottom: 1px solid #f1f5f9;
user-select: none;
display: flex;
align-items: center;
.nav-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding: 0 16px;
box-sizing: border-box;
// 全部分类按钮
.all-category-btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 100%;
padding: 0 18px;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
margin-right: 20px;
transition: opacity 0.2s;
&:hover {
opacity: 0.92;
}
.all-icon-img {
width: 18px;
height: 18px;
object-fit: contain;
}
.all-icon-svg {
font-size: 16px;
}
.all-text {
letter-spacing: 0.5px;
}
.all-arrow {
font-size: 12px;
margin-left: 2px;
}
}
// 分类项列表
.nav-items-wrapper {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 28px;
overflow-x: auto;
scrollbar-width: none; // Firefox
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
.cat-item {
display: inline-flex;
align-items: center;
gap: 6px;
height: 100%;
cursor: pointer;
position: relative;
flex-shrink: 0;
transition: transform 0.15s ease;
.cat-icon {
width: 22px;
height: 22px;
object-fit: contain;
border-radius: 4px;
transition: transform 0.2s ease;
}
.cat-title {
font-size: 14px;
font-weight: 500;
color: var(--text-color, #334155);
transition: color 0.2s ease;
line-height: 1.2;
white-space: nowrap;
}
.cat-badge {
position: absolute;
top: 4px;
right: -12px;
color: #ffffff;
font-size: 9px;
line-height: 1;
padding: 2px 4px;
border-radius: 8px;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
&:hover {
.cat-icon {
transform: scale(1.12);
}
.cat-title {
color: var(--active-color, #e11d48);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,535 @@
<template>
<div class="tpl-channel-floor" :style="customStyle">
<!-- 1. 标题区 -->
<div class="channel-floor-header" v-if="options.showTitle !== false && options.title">
<div class="header-main-title">
<span class="title-pill"></span>
<span class="title-text">{{ options.title }}</span>
<span class="subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="header-more">
<span>探索更多场景 &gt;</span>
</div>
</div>
<!-- 2. 场景频道组合布局:左侧主视觉+活动入口 + 右侧商品网格 -->
<div class="channel-floor-body" :class="{ 'no-hero': !showHero }">
<!-- 左侧主视觉卡片 (Hero Card) -->
<div
class="channel-hero-card"
:class="[`style-${options.heroStyle || 'gradient'}`]"
v-if="showHero"
>
<div class="hero-top-info">
<div class="hero-badge">SCENE CHANNEL</div>
<h3 class="hero-title">{{ options.heroTitle || '品质生活馆' }}</h3>
<p class="hero-sub">{{ options.heroSubtitle || '精选场景好物,一站购齐' }}</p>
</div>
<div class="hero-image-wrap">
<img :src="heroImg" alt="channel hero" class="hero-img" />
<div class="hero-cta-btn">
<span>{{ options.heroButtonText || '进入频道' }}</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
</svg>
</div>
</div>
<!-- 专属活动入口 (Promos) -->
<div class="hero-promos-wrap" v-if="promosList.length">
<div
v-for="(p, pIdx) in promosList"
:key="pIdx"
class="hero-promo-item"
>
<span class="promo-tag" v-if="p.tag">{{ p.tag }}</span>
<div class="promo-text-box">
<span class="promo-title">{{ p.title }}</span>
<span class="promo-desc">{{ p.desc }}</span>
</div>
<span class="promo-arrow">&gt;</span>
</div>
</div>
</div>
<!-- 右侧商品网格 (Scene Goods Grid) -->
<div
class="channel-goods-grid"
:class="`cols-${rightColumns}`"
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in displayGoods"
:key="g.id || idx"
class="scene-goods-card"
>
<div class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
<span class="scene-tag">场景精选</span>
</div>
<div class="card-info">
<div class="goods-name">{{ g.goodsName }}</div>
<div class="price-action-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ g.price }}</span>
<span class="orig" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button type="button" class="cart-btn" title="立即加购">+</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const heroImg = computed(() => {
return props.options?.heroImage || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
});
const showHero = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
return false;
}
return true;
});
const rightColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return showHero.value ? 2 : 3;
if (ratio === '66.67%' || ratio === '66.6%') return showHero.value ? 3 : 4;
return 3; // 100% 满宽时:左侧主视觉 (320px) + 右侧 3 列或 4 列双行
});
const promosList = computed(() => {
if (Array.isArray(props.options?.promos) && props.options.promos.length > 0) {
return props.options.promos;
}
return [
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时' },
{ title: '新品驾到', desc: '官方正品首发', tag: '新品' }
];
});
const defaultGoods = [
{ 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: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options?.goodsCount || 6;
return defaultGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '16px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-channel-floor {
width: 100%;
box-sizing: border-box;
user-select: none;
.channel-floor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 2px solid #f1f5f9;
.header-main-title {
display: flex;
align-items: center;
gap: 10px;
.title-pill {
width: 4px;
height: 18px;
background: linear-gradient(180deg, #6366f1, #4f46e5);
border-radius: 2px;
}
.title-text {
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.subtitle-text {
font-size: 13px;
color: #94a3b8;
margin-left: 2px;
}
}
.header-more {
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #4f46e5;
}
}
}
.channel-floor-body {
display: flex;
gap: 16px;
align-items: stretch;
&.no-hero {
.channel-goods-grid {
width: 100%;
}
}
/* 左侧场景主视觉卡片 */
.channel-hero-card {
width: 320px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 20px 18px 16px;
box-sizing: border-box;
position: relative;
transition: all 0.3s ease;
&.style-gradient {
background: linear-gradient(145deg, #1e1b4b, #312e81 60%, #4338ca);
color: #ffffff;
}
&.style-plain {
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #1e293b;
.hero-badge {
background: #e0e7ff;
color: #4338ca;
}
.hero-title {
color: #0f172a;
}
.hero-sub {
color: #64748b;
}
.hero-cta-btn {
background: #4f46e5;
color: #ffffff;
}
}
.hero-top-info {
margin-bottom: 12px;
.hero-badge {
display: inline-block;
font-size: 10px;
font-weight: 800;
letter-spacing: 1px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
margin-bottom: 8px;
}
.hero-title {
font-size: 22px;
font-weight: 800;
margin: 0 0 4px 0;
line-height: 1.25;
}
.hero-sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
line-height: 1.4;
}
}
.hero-image-wrap {
position: relative;
width: 100%;
height: 220px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 14px;
background: rgba(0, 0, 0, 0.1);
.hero-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
&:hover .hero-img {
transform: scale(1.04);
}
.hero-cta-btn {
position: absolute;
bottom: 10px;
right: 10px;
background: #ffffff;
color: #312e81;
font-size: 12px;
font-weight: 700;
padding: 6px 14px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 4px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #4f46e5;
color: #ffffff;
}
}
}
.hero-promos-wrap {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
.hero-promo-item {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.22);
transform: translateX(3px);
}
.promo-tag {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 3px;
background: #ef4444;
color: #ffffff;
}
.promo-text-box {
display: flex;
flex-direction: column;
flex: 1;
.promo-title {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.promo-desc {
font-size: 11px;
opacity: 0.85;
}
}
.promo-arrow {
font-size: 13px;
opacity: 0.7;
}
}
}
}
/* 右侧场景商品网格 */
.channel-goods-grid {
flex: 1;
display: grid;
gap: 12px;
.scene-goods-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
.card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
.goods-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
&:hover .goods-img {
transform: scale(1.05);
}
.scene-tag {
position: absolute;
top: 6px;
left: 6px;
background: #4f46e5;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-info {
display: flex;
flex-direction: column;
flex: 1;
.goods-name {
font-size: 14px;
color: #1e293b;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 6px;
}
.price-action-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
gap: 1px;
.sym {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
.val {
font-size: 16px;
font-weight: 800;
color: #e11d48;
}
.orig {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 3px;
}
}
.cart-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #4f46e5;
color: #ffffff;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="tpl-channel-grid" :style="customStyle">
<div
class="grid-wrap"
:style="{
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<div
v-for="(item, idx) in options.items"
:key="idx"
class="channel-card"
:class="[
`is-${layoutMode}`,
{ 'is-compact': isCompact }
]"
@click="handleClickItem(item)"
>
<!-- 图标/图片 -->
<div
class="card-thumb"
:style="{ borderRadius: `${options.imageRadius ?? 10}px` }"
>
<img
v-if="item.img"
:src="item.img"
:alt="item.title"
class="thumb-img"
/>
<div v-else class="thumb-placeholder">
<span>{{ (item.title || '频').charAt(0) }}</span>
</div>
</div>
<!-- 主标题与副标题 -->
<div class="card-text">
<div class="main-title">{{ item.title || '频道名称' }}</div>
<div class="sub-title">{{ item.subtitle || '副标题文案' }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
// 排版宽度比例 (整行: 100%, 三分之二: 66.67%, 一半: 50%, 三分之一: 33.33%, 六分之一: 16.67%)
const widthRatio = computed(() => {
return props.componentStyle?.widthRatio || '100%';
});
// 响应式列数:50%、33.33%、16.67% 为 2 列;100%、66.67% 为 4 列
const gridColumns = computed(() => {
const w = widthRatio.value;
if (w === '50%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
return 2;
}
return 4;
});
// 卡片内部排布方式:
// 100% (整行)、50% (一半) -> 水平横向 (左图右文)
// 66.67% (三分之二)、33.33% (三分之一)、16.67% (六分之一) -> 垂直纵向 (上图下文居中)
const layoutMode = computed(() => {
const w = widthRatio.value;
if (w === '66.67%' || w === '66.6%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
return 'vertical';
}
return 'horizontal';
});
// 六分之一紧凑模式
const isCompact = computed(() => {
const w = widthRatio.value;
return w === '16.67%' || w === '16.6%';
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
function handleClickItem(item) {
if (item && item.url) {
console.log('Navigate to:', item.url);
}
}
</script>
<style lang="scss" scoped>
.tpl-channel-grid {
width: 100%;
user-select: none;
box-sizing: border-box;
.grid-wrap {
display: grid;
width: 100%;
.channel-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
cursor: pointer;
box-sizing: border-box;
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
border-color: #e2e8f0;
.card-thumb .thumb-img {
transform: scale(1.06);
}
.card-text .main-title {
color: #ea580c;
}
}
.card-thumb {
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
.thumb-img {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
}
.thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f97316, #ea580c);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
}
}
.card-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
.main-title {
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s;
}
.sub-title {
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 1. 水平横向排布 (整行 100%、一半 50%)
&.is-horizontal {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 16px;
.card-thumb {
width: 44px;
height: 44px;
}
.card-text {
flex: 1;
gap: 3px;
text-align: left;
.main-title {
font-size: 14px;
line-height: 1.3;
}
.sub-title {
font-size: 12px;
line-height: 1.2;
}
}
}
// 2. 垂直纵向排布 (三分之二 66.67%、三分之一 33.33%、六分之一 16.67%)
&.is-vertical {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
padding: 14px 10px 12px 10px;
.card-thumb {
width: 44px;
height: 44px;
margin: 0 auto;
}
.card-text {
width: 100%;
gap: 2px;
text-align: center;
.main-title {
font-size: 13px;
line-height: 1.3;
}
.sub-title {
font-size: 12px;
line-height: 1.2;
}
}
}
// 3. 六分之一紧凑模式
&.is-compact {
padding: 10px 6px 8px 6px;
gap: 5px;
.card-thumb {
width: 36px;
height: 36px;
}
.card-text {
gap: 1px;
.main-title {
font-size: 12px;
}
.sub-title {
font-size: 11px;
transform: scale(0.95);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="tpl-free-layout" :style="customStyle">
<div class="layout-hint">
<span>自由布局画布区 (高度: {{ options.height || 260 }}px)</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 450}px`,
backgroundColor: s.backgroundColor || '#f8fafc',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-free-layout {
width: 100%;
border: 1px dashed #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
box-sizing: border-box;
.layout-hint {
color: #94a3b8;
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div class="tpl-goods-list" :style="customStyle">
<!-- 标题栏 -->
<div class="floor-title-bar" v-if="options.showTitle !== false && options.title">
<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;
</span>
</div>
<!-- 6 列商品列表网格 (整行展示 6 个商品) -->
<div
class="goods-flow-grid"
:class="`cols-${actualColumns}`"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in sampleGoods"
:key="g.id || idx"
class="goods-flow-card"
>
<div class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="thumb-img" />
<span class="self-tag" v-if="idx % 3 === 0">自营</span>
</div>
<div class="card-body">
<div class="goods-name" v-if="options.showTitle !== false">
{{ g.goodsName }}
</div>
<div class="price-row">
<div class="price-box">
<span class="symbol">¥</span>
<span class="price-val" v-if="options.showPrice !== false">{{ g.price }}</span>
<span class="orig-price" v-if="options.showOriginalPrice && g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button
type="button"
class="add-cart-circle"
v-if="options.showCartBtn !== false"
title="加入购物车"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const defaultMockGoods = [
{ 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: '折叠屏旗舰手机 典藏陶瓷白 悬停双旋水滴铰链', price: '6999.00', originalPrice: '7999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '10', goodsName: '真无线触控降噪蓝牙耳机 超低延迟游戏降噪', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '11', goodsName: '国AA级减蓝光润眼长臂台灯 智能感光多档色温无极调光', price: '249.00', originalPrice: '359.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ 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;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 3);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 4);
}
return baseCols; // 100% 默认 6 列
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '14px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
const sampleGoods = computed(() => {
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;
return defaultMockGoods.slice(0, count);
});
</script>
<style lang="scss" scoped>
.tpl-goods-list {
width: 100%;
user-select: none;
box-sizing: border-box;
.floor-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 0 4px;
.title-left {
display: flex;
align-items: baseline;
gap: 10px;
.main-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
}
.sub-title {
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: 12px;
color: #64748b;
}
}
.goods-flow-grid {
display: grid;
gap: 12px;
.goods-flow-card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #f1f5f9;
padding: 10px;
display: flex;
flex-direction: column;
.card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.self-tag {
position: absolute;
top: 6px;
left: 6px;
background: #e11d48;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-name {
font-size: 14px;
font-weight: 500;
color: #1e293b;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #94a3b8;
margin-bottom: 6px;
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.symbol {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
.price-val {
font-size: 16px;
font-weight: 800;
color: #e11d48;
}
.orig-price {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 3px;
}
}
.add-cart-circle {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<div class="tpl-goods-grid" :style="customStyle">
<!-- 宫格顶部标题区 -->
<div class="grid-header" v-if="options.title">
<div class="grid-title-wrap">
<span class="grid-title-decor"></span>
<h3 class="grid-main-title">{{ options.title }}</h3>
<span class="grid-sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="grid-more" v-if="options.showMoreLink !== false">
<span>{{ options.moreText || '查看全部' }} &gt;</span>
</div>
</div>
<!-- 紧凑严谨的商品九宫格/矩阵网格 (Bordered Matrix Grid) -->
<div
class="matrix-grid-container"
:class="[`cols-${actualColumns}`, { 'with-grid-border': options.showBorder !== false }]"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="matrix-grid-cell"
>
<div class="cell-rank-tag" v-if="idx < 5">
<span class="rank-num">TOP {{ idx + 1 }}</span>
</div>
<div class="cell-thumb-box">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" class="cell-thumb" />
</div>
<div class="cell-body">
<div class="cell-name">{{ item.goodsName }}</div>
<div class="cell-meta">
<span class="badge-label">热卖好物</span>
</div>
<div class="cell-bottom-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
<span class="del" v-if="item.originalPrice">¥{{ item.originalPrice }}</span>
</div>
<span class="action-add-pill" title="立即选购">+</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const actualColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
const base = Number(props.options?.columns) || 5;
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return base; // 100% 默认 5 列宫格
});
const defaultGridGoods = [
{ 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' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = (actualColumns.value * 2) || 10;
return defaultGridGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '18px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-goods-grid {
width: 100%;
box-sizing: border-box;
user-select: none;
.grid-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
.grid-title-wrap {
display: flex;
align-items: center;
gap: 10px;
.grid-title-decor {
width: 4px;
height: 18px;
background: #f97316;
border-radius: 2px;
}
.grid-main-title {
font-size: 19px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.grid-sub-title {
font-size: 12px;
color: #64748b;
margin-left: 4px;
}
}
.grid-more {
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #f97316;
}
}
}
/* 宫格矩阵:带整体边框与单元格实线分隔线 */
.matrix-grid-container {
display: grid;
background: #ffffff;
box-sizing: border-box;
&.with-grid-border {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
.matrix-grid-cell {
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
}
}
.matrix-grid-cell {
position: relative;
padding: 16px 14px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
transition: all 0.2s ease;
&:hover {
background: #fafbfc;
z-index: 1;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
.cell-thumb {
transform: scale(1.06);
}
.action-add-pill {
background: #f97316;
color: #ffffff;
}
}
.cell-rank-tag {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
.rank-num {
display: inline-block;
font-size: 10px;
font-weight: 800;
color: #f97316;
background: #fff7ed;
border: 1px solid #fed7aa;
padding: 1px 6px;
border-radius: 4px;
}
}
.cell-thumb-box {
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 12px;
.cell-thumb {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
}
.cell-body {
display: flex;
flex-direction: column;
flex: 1;
.cell-name {
font-size: 14px;
color: #334155;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 6px;
}
.cell-meta {
display: flex;
margin-bottom: 8px;
.badge-label {
font-size: 10px;
color: #d97706;
background: #fef3c7;
padding: 1px 6px;
border-radius: 3px;
}
}
.cell-bottom-row {
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
gap: 1px;
.sym {
font-size: 12px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 18px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.action-add-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #f1f5f9;
color: #64748b;
font-size: 15px;
font-weight: 600;
line-height: 1;
transition: all 0.2s;
cursor: pointer;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,534 @@
<template>
<div class="tpl-grid-floor" :style="customStyle">
<!-- 楼层大标题栏 (Floor Title Bar) -->
<div class="floor-title-header" v-if="options.showFloorTitle !== false">
<div class="floor-main-heading">
<span class="floor-badge">{{ options.floorNum || '1F' }}</span>
<span class="floor-title-text">{{ options.title || '潮流数码' }}</span>
<span class="floor-subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="floor-header-right">
<!-- 子分类快捷标签 -->
<div class="sub-category-tags" v-if="subCategories.length">
<span
v-for="(tag, idx) in subCategories"
:key="idx"
class="tag-item"
:class="{ active: activeTagIdx === idx }"
@click="activeTagIdx = idx"
>
{{ tag }}
</span>
</div>
<span class="more-link">查看更多 &gt;</span>
</div>
</div>
<!-- 楼层主体:左侧竖向营销海报 + 右侧商品网格 -->
<div class="floor-main-body" :class="{ 'no-poster': !showPoster }">
<!-- 左侧营销大促海报 -->
<div class="floor-left-poster" v-if="showPoster">
<div class="poster-inner">
<img :src="posterImg" alt="floor poster" class="poster-img" />
<div class="poster-overlay">
<div class="poster-top-badge">{{ options.floorNum || '1F' }} 甄选</div>
<h4 class="poster-headline">{{ options.posterTitle || '数码潮电 酷玩季' }}</h4>
<p class="poster-subline">{{ options.posterSub || '领券最高立减 600 元' }}</p>
<div class="poster-pill-tags" v-if="posterTags.length">
<span v-for="(pt, pIdx) in posterTags" :key="pIdx" class="pill-tag">{{ pt }}</span>
</div>
<div class="poster-action-btn">
<span>立即抢购 &gt;</span>
</div>
</div>
</div>
</div>
<!-- 右侧商品网格 (2行 x N列) -->
<div
class="floor-right-goods"
:class="`cols-${rightColumns}`"
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in displayGoods"
:key="g.id || idx"
class="floor-goods-card"
>
<div class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
<span class="corner-badge" v-if="idx < 3">HOT</span>
</div>
<div class="card-detail">
<div class="goods-name-line">{{ g.goodsName }}</div>
<div class="goods-tag-row">
<span class="mini-tag">官方直营</span>
<span class="mini-tag light">免息</span>
</div>
<div class="price-action-row">
<div class="price-group">
<span class="currency">¥</span>
<span class="amount">{{ g.price }}</span>
<span class="orig-amount" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button type="button" class="buy-action-btn" title="查看商品">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M11 9h2V6h3V4h-3V1h-2v3H8v2h3v3zm-4 9c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zm10 0c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2zm-9.83-3.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.86-7.01L19.42 4h-.01l-1.1 2-2.76 5H8.53l-.13-.27L6.16 6l-.95-2-.94-2H1v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.13 0-.25-.11-.25-.25z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const activeTagIdx = ref(0);
const subCategories = computed(() => {
if (Array.isArray(props.options?.subCategories) && props.options.subCategories.length > 0) {
return props.options.subCategories;
}
return ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
});
const posterTags = computed(() => {
if (Array.isArray(props.options?.posterTags) && props.options.posterTags.length > 0) {
return props.options.posterTags;
}
return ['爆款低至5折', '新品首发', '分期免息'];
});
const posterImg = computed(() => {
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(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
// 如果宽度太窄(如六分之一或三分之一),隐藏左侧海报避免挤压
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
return false;
}
return props.options?.showPoster !== false;
});
const rightColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
let baseCols = Number(props.options?.columns) || 4;
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return showPoster.value ? 2 : 3;
if (ratio === '66.67%' || ratio === '66.6%') return showPoster.value ? 3 : 4;
return baseCols; // 100% 默认 4 列
});
const defaultFloorGoods = [
{ id: '101', 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: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = (rightColumns.value * 2) || 8;
return defaultFloorGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '16px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-grid-floor {
width: 100%;
box-sizing: border-box;
user-select: none;
/* 楼层大标题栏 */
.floor-title-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 2px solid #f1f5f9;
margin-bottom: 16px;
.floor-main-heading {
display: flex;
align-items: center;
gap: 10px;
.floor-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
font-weight: 800;
font-size: 13px;
padding: 3px 10px;
border-radius: 4px;
letter-spacing: 0.5px;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
}
.floor-title-text {
font-size: 20px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.2px;
}
.floor-subtitle-text {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
margin-left: 4px;
}
}
.floor-header-right {
display: flex;
align-items: center;
gap: 16px;
.sub-category-tags {
display: flex;
align-items: center;
gap: 8px;
.tag-item {
font-size: 13px;
color: #64748b;
padding: 4px 10px;
border-radius: 14px;
cursor: pointer;
transition: all 0.2s;
&:hover, &.active {
color: #2563eb;
background: #eff6ff;
font-weight: 600;
}
}
}
.more-link {
font-size: 13px;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
transition: color 0.2s;
&:hover {
color: #2563eb;
}
}
}
}
/* 楼层主体 */
.floor-main-body {
display: flex;
gap: 16px;
align-items: stretch;
&.no-poster {
.floor-right-goods {
width: 100%;
}
}
/* 左侧竖向海报 */
.floor-left-poster {
width: 240px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
position: relative;
background: #1e293b;
.poster-inner {
position: relative;
width: 100%;
height: 100%;
min-height: 520px;
.poster-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(0.85);
transition: transform 0.4s ease;
}
&:hover .poster-img {
transform: scale(1.03);
}
.poster-overlay {
position: absolute;
inset: 0;
padding: 24px 18px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.75) 0%, rgba(15, 23, 42, 0.2) 40%, rgba(15, 23, 42, 0.85) 100%);
color: #ffffff;
.poster-top-badge {
align-self: flex-start;
font-size: 11px;
font-weight: 700;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.poster-headline {
font-size: 22px;
font-weight: 800;
line-height: 1.3;
margin: 0 0 6px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
.poster-subline {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin: 0 0 16px 0;
line-height: 1.4;
}
.poster-pill-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: auto;
.pill-tag {
font-size: 11px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.18);
border-radius: 10px;
backdrop-filter: blur(4px);
}
}
.poster-action-btn {
align-self: flex-start;
margin-top: auto;
background: #ffffff;
color: #0f172a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
cursor: pointer;
&:hover {
background: #2563eb;
color: #ffffff;
}
}
}
}
}
/* 右侧商品网格 */
.floor-right-goods {
flex: 1;
display: grid;
gap: 12px;
.floor-goods-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
.card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 10px;
.goods-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
&:hover .goods-img {
transform: scale(1.05);
}
.corner-badge {
position: absolute;
top: 6px;
left: 6px;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
}
.card-detail {
display: flex;
flex-direction: column;
flex: 1;
.goods-name-line {
font-size: 14px;
color: #1e293b;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 6px;
}
.goods-tag-row {
display: flex;
gap: 4px;
margin-bottom: 8px;
.mini-tag {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: #fee2e2;
color: #dc2626;
&.light {
background: #eff6ff;
color: #2563eb;
}
}
}
.price-action-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-group {
display: flex;
align-items: baseline;
gap: 2px;
.currency {
font-size: 12px;
font-weight: 700;
color: #e11d48;
}
.amount {
font-size: 17px;
font-weight: 800;
color: #e11d48;
}
.orig-amount {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.buy-action-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #2563eb;
color: #ffffff;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="tpl-hot-zone" :style="customStyle">
<div class="zone-wrap" v-if="displayImageUrl">
<img :src="displayImageUrl" alt="hotzone" />
<div
v-for="(zone, idx) in displayZones"
:key="idx"
class="zone-mask"
:style="{
top: `${zone.top !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}"
>
<span class="zone-label">{{ zone.title || `热区 ${idx + 1}` }}</span>
</div>
</div>
<div v-else class="hotzone-empty">
<span>热区图待配置底图</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultZones = [
{ title: '大促主会场 · 领券中心', url: '/couponCenter', left: 3.5, top: 12, width: 28.5, height: 76 },
{ title: '数码潮电馆 · 爆款直降', url: '/goodsList?category=1', left: 35.5, top: 12, width: 28.5, height: 76 },
{ title: '美妆奢品馆 · 5折特惠', url: '/goodsList?category=2', left: 67.5, top: 12, width: 29, height: 76 }
];
const displayImageUrl = computed(() => {
return props.options?.imageUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/hot-zone/hotzone_default.png';
});
const displayZones = computed(() => {
if (Array.isArray(props.options?.zones) && props.options.zones.length > 0) {
return props.options.zones;
}
return defaultZones;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
overflow: 'hidden'
};
});
</script>
<style lang="scss" scoped>
.tpl-hot-zone {
width: 100%;
position: relative;
user-select: none;
box-sizing: border-box;
.zone-wrap {
position: relative;
width: 100%;
border-radius: inherit;
overflow: hidden;
img {
width: 100%;
display: block;
border-radius: inherit;
object-fit: cover;
}
.zone-mask {
position: absolute;
background: rgba(37, 99, 235, 0.22);
border: 1.5px dashed #2563eb;
color: #ffffff;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
box-shadow: 0 0 10px rgba(37, 99, 235, 0.35);
transition: all 0.2s ease;
&:hover {
background: rgba(37, 99, 235, 0.35);
border-color: #3b82f6;
}
.zone-label {
font-size: 12px;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
padding: 3px 6px;
background: rgba(15, 23, 42, 0.65);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
}
}
.hotzone-empty {
padding: 40px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,298 @@
<template>
<div
class="tpl-notice-bar"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
@mouseenter="stopTimer"
@mouseleave="startTimer"
>
<!-- 左侧快讯徽章 -->
<div class="notice-badge-box">
<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>
</div>
<span class="badge-title">{{ options.title || '公告快讯' }}</span>
<span class="badge-divider"></span>
</div>
<!-- 中间动态滚动快讯区 (Vertical Carousel) -->
<div class="notice-content-viewport">
<div
class="notice-items-strip"
:style="{ transform: `translateY(-${currentIndex * 40}px)` }"
>
<div
v-for="(item, idx) in noticeList"
:key="idx"
class="notice-strip-item"
>
<span class="notice-item-tag" :class="getTagClass(item.tag)">
{{ item.tag || '最新' }}
</span>
<span class="notice-item-title">{{ item.title || item.text }}</span>
<span class="notice-item-time" v-if="item.time && !isMini && !isCompact">
{{ item.time }}
</span>
</div>
</div>
</div>
<!-- 右侧更多链接与翻页指示 -->
<div class="notice-more-action" v-if="options.showMore !== false && !isMini">
<span class="more-text">{{ options.moreText || '更多' }} &gt;</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const isMini = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '16.67%' || r === '16.6%' || r === '16.7%';
});
const isCompact = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '33.33%' || r === '33.3%';
});
const defaultItems = [
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
];
const noticeList = computed(() => {
const list = props.options?.items || props.options?.notices;
if (Array.isArray(list) && list.length > 0) {
return list;
}
return defaultItems;
});
const currentIndex = ref(0);
let timer = null;
function startTimer() {
if (timer) clearInterval(timer);
if (noticeList.value.length <= 1) return;
timer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % noticeList.value.length;
}, 3500);
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function getTagClass(tag) {
if (tag === '活动') return 'tag-promo';
if (tag === '通知') return 'tag-notice';
if (tag === '热议') return 'tag-hot';
return 'tag-new';
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '0 16px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-notice-bar {
width: 100%;
height: 44px;
display: flex;
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: 8px;
flex-shrink: 0;
.badge-icon {
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: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
}
.badge-divider {
width: 1px;
height: 14px;
background: #cbd5e1;
margin-left: 4px;
}
}
.notice-content-viewport {
flex: 1;
min-width: 0;
height: 40px;
overflow: hidden;
position: relative;
.notice-items-strip {
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;
box-sizing: border-box;
.notice-item-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
flex-shrink: 0;
&.tag-new {
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;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notice-item-time {
font-size: 11px;
color: #94a3b8;
flex-shrink: 0;
}
}
}
}
.notice-more-action {
flex-shrink: 0;
.more-text {
font-size: 12px;
color: #64748b;
}
}
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="tpl-promo-group" :style="customStyle">
<div
class="promo-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(card, idx) in cardsList"
:key="idx"
class="card-item"
:style="card.bgImg ? { backgroundImage: `url(${card.bgImg})` } : {}"
>
<span class="m-t">{{ card.title }}</span>
<span class="s-t">{{ card.subtitle }}</span>
<span class="btn">立即查看 ></span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultCards = [
{ title: '限时秒杀', subtitle: '抢券立减50元 · 爆款直降', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_1.png', url: '/goodsList?seckill=1' },
{ title: '品牌特惠', subtitle: '大牌买一送一 · 领百元神券', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_2.png', url: '/goodsList?brand=1' },
{ title: '新品首发', subtitle: '首单专享特惠 · 1元起尝鲜', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_3.png', url: '/goodsList?new=1' }
];
const cardsList = computed(() => {
if (Array.isArray(props.options?.cards) && props.options.cards.length > 0) {
return props.options.cards;
}
return defaultCards;
});
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 3;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return 1;
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 2);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
</script>
<style lang="scss" scoped>
.tpl-promo-group {
width: 100%;
user-select: none;
box-sizing: border-box;
.promo-grid {
display: grid;
gap: 16px;
.card-item {
padding: 24px;
min-height: 150px;
background-color: #1e293b;
background-size: cover;
background-position: center;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
color: #ffffff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
.m-t {
font-size: 22px;
font-weight: 800;
color: #ffffff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.s-t {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
margin: 6px 0 14px 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.btn {
display: inline-block;
align-self: flex-start;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 14px;
font-size: 12px;
font-weight: 600;
color: #ffffff;
}
}
}
}
</style>

View File

@@ -0,0 +1,658 @@
<template>
<div class="tpl-recommend-feed" :style="customStyle">
<!-- 1. 推荐流居中标题栏 -->
<div class="recommend-feed-header">
<div class="header-decor-line left"></div>
<div class="header-center-title">
<svg class="heart-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<h3 class="main-title">{{ options.title || '猜你喜欢' }}</h3>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="header-decor-line right"></div>
</div>
<!-- 2. 推荐流布局:精选大卡+网格 (featured) 或 标准网格 (grid) -->
<!-- A. 精选大卡+网格 (featured 布局) -->
<div
v-if="(options.layoutStyle || 'featured') === 'featured'"
class="recommend-featured-layout"
>
<!-- 左侧精选特大卡片 (Featured Hero Card) -->
<div class="featured-big-card" v-if="featuredGoods">
<div class="big-card-badge">为你特别甄选</div>
<div class="big-card-thumb">
<img :src="featuredGoods.thumbnail || defaultThumb" :alt="featuredGoods.goodsName" />
</div>
<div class="big-card-info">
<div class="big-title">{{ featuredGoods.goodsName }}</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>
<span class="val">{{ featuredGoods.price }}</span>
<span class="del" v-if="featuredGoods.originalPrice">¥{{ featuredGoods.originalPrice }}</span>
</div>
<button type="button" class="big-buy-btn">立即入手 &gt;</button>
</div>
</div>
</div>
<!-- 右侧与后续标准卡片网格 -->
<div class="featured-sub-grid">
<div
v-for="(item, idx) in regularGoods"
:key="item.id || idx"
class="recommend-card"
>
<div class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">推荐</span>
</div>
<div class="card-body">
<div class="goods-title">{{ item.goodsName }}</div>
<div class="recommend-meta">
<span class="reason-tag">同品类热度榜</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn">+</button>
</div>
</div>
</div>
</div>
</div>
<!-- B. 标准网格流 (grid 布局) -->
<div
v-else
class="recommend-standard-grid"
:class="`cols-${actualCols}`"
:style="{ gridTemplateColumns: `repeat(${actualCols}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="recommend-card"
>
<div class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">为你推荐</span>
</div>
<div class="card-body">
<div class="goods-title">{{ item.goodsName }}</div>
<div class="recommend-meta">
<span class="reason-tag">甄选推荐</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn">+</button>
</div>
</div>
</div>
</div>
<!-- 底部换一批 / 探索更多推荐 -->
<div class="recommend-footer-action">
<button type="button" class="refresh-rec-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
<span>换一批推荐好物</span>
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const defaultPool = [
{ 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' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options?.pageSize || 8;
return defaultPool.slice(0, count);
});
const featuredGoods = computed(() => {
return displayGoods.value[0] || defaultPool[0];
});
const regularGoods = computed(() => {
return displayGoods.value.slice(1, 7);
});
const actualCols = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return 5;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const bg = props.options?.backgroundColor || s.backgroundColor || '#f8fafc';
return {
backgroundColor: bg,
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '24px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-recommend-feed {
width: 100%;
box-sizing: border-box;
user-select: none;
.recommend-feed-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
gap: 16px;
.header-decor-line {
flex: 1;
max-width: 120px;
height: 1px;
background: linear-gradient(90deg, transparent, #cbd5e1);
&.right {
background: linear-gradient(90deg, #cbd5e1, transparent);
}
}
.header-center-title {
display: flex;
align-items: center;
gap: 8px;
.heart-icon {
color: #ef4444;
}
.main-title {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin: 0;
}
.sub-title {
font-size: 13px;
color: #64748b;
margin-left: 6px;
}
}
}
/* 精选大卡 + 网格复合布局 */
.recommend-featured-layout {
display: flex;
gap: 14px;
align-items: stretch;
.featured-big-card {
width: 320px;
flex-shrink: 0;
background: #ffffff;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
position: relative;
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.big-card-badge {
position: absolute;
top: 12px;
left: 12px;
background: linear-gradient(135deg, #ef4444, #f97316);
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
z-index: 2;
}
.big-card-thumb {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 6px;
margin-bottom: 12px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
img {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.big-card-info {
display: flex;
flex-direction: column;
flex: 1;
.big-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
line-height: 1.4;
margin-bottom: 6px;
}
.big-reason {
font-size: 12px;
color: #f97316;
background: #fff7ed;
padding: 4px 8px;
border-radius: 4px;
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;
align-items: center;
justify-content: space-between;
.big-price {
display: flex;
align-items: baseline;
.sym {
font-size: 13px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 20px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.big-buy-btn {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 6px 14px;
border-radius: 16px;
border: none;
cursor: pointer;
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: scale(1.03);
}
}
}
}
}
.featured-sub-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
}
/* 标准网格流 */
.recommend-standard-grid {
display: grid;
gap: 12px;
}
.recommend-card {
background: #ffffff;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
border: 1px solid #f1f5f9;
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
.card-thumb img {
transform: scale(1.05);
}
.cart-pill-btn {
background: #ef4444;
color: #ffffff;
}
}
.card-thumb {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.rec-tag {
position: absolute;
top: 6px;
left: 6px;
background: #ef4444;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-title {
font-size: 14px;
color: #1e293b;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
}
.recommend-meta {
font-size: 10px;
color: #f97316;
margin-bottom: 6px;
.reason-tag {
background: #fff7ed;
padding: 1px 5px;
border-radius: 3px;
}
.sales-num {
color: #94a3b8;
margin-right: 6px;
}
.rate-num {
color: #10b981;
}
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.sym {
font-size: 11px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 16px;
font-weight: 800;
color: #dc2626;
}
}
.cart-pill-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
.recommend-footer-action {
display: flex;
justify-content: center;
margin-top: 20px;
.refresh-rec-btn {
display: flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
font-weight: 500;
padding: 8px 24px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: all 0.2s;
&:hover {
border-color: #ef4444;
color: #ef4444;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
}
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div
class="tpl-title-bar"
:class="[
`align-${options.align || 'left'}`,
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
>
<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>
<span class="more-link" v-if="options.showMore">{{ options.moreText || '查看更多 >' }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
</script>
<style lang="scss" scoped>
.tpl-title-bar {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
user-select: none;
box-sizing: border-box;
gap: 8px;
&.align-center {
justify-content: center;
position: relative;
.more-link {
position: absolute;
right: 0;
}
}
.title-content {
display: flex;
align-items: center;
flex-wrap: wrap;
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;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub-t {
font-size: 13px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.more-link {
font-size: 13px;
color: #64748b;
white-space: nowrap;
flex-shrink: 0;
}
&.is-compact {
.title-content {
.title-custom-icon {
width: 20px;
height: 20px;
}
.main-t {
font-size: 18px;
}
.sub-t {
font-size: 12px;
}
}
.more-link {
font-size: 12px;
}
}
&.is-mini {
flex-direction: column;
align-items: flex-start;
gap: 6px;
&.align-center {
align-items: center;
.more-link {
position: static;
margin-top: 4px;
}
}
.title-content {
.title-custom-icon {
width: 16px;
height: 16px;
}
.main-t {
font-size: 15px;
}
.sub-t {
font-size: 11px;
}
}
.more-link {
font-size: 11px;
}
}
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="tpl-video" :style="customStyle">
<div class="video-preview-box">
<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>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
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'
};
});
</script>
<style lang="scss" scoped>
.tpl-video {
width: 100%;
background: #0f172a;
overflow: hidden;
user-select: none;
box-sizing: border-box;
.video-preview-box {
width: 100%;
height: 100%;
position: relative;
display: flex;
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: 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: 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>

View File

@@ -0,0 +1,53 @@
import TplCategoryNav from './TplCategoryNav.vue';
import TplChannelGrid from './TplChannelGrid.vue';
import TplCategoryMegaMenu from './TplCategoryMegaMenu.vue';
import TplCarousel from './TplCarousel.vue';
import TplBrandWall from './TplBrandWall.vue';
import TplHotZone from './TplHotZone.vue';
import TplFreeLayout from './TplFreeLayout.vue';
import TplPromoGroup from './TplPromoGroup.vue';
import TplVideo from './TplVideo.vue';
import TplGoods from './TplGoods.vue';
import TplGridFloor from './TplGridFloor.vue';
import TplGoodsGrid from './TplGoodsGrid.vue';
import TplChannelFloor from './TplChannelFloor.vue';
import TplRecommendFeed from './TplRecommendFeed.vue';
import TplTitle from './TplTitle.vue';
import TplNotice from './TplNotice.vue';
export const CANVAS_TEMPLATE_MAP = {
// 结构 (3)
// 导航 (3)
categoryNav: TplCategoryNav,
channelGrid: TplChannelGrid,
categoryMegaMenu: TplCategoryMegaMenu,
// 广告 (6)
carousel: TplCarousel,
brandWall: TplBrandWall,
hotZone: TplHotZone,
freeLayout: TplFreeLayout,
promoGroup: TplPromoGroup,
video: TplVideo,
// 商品 (8)
goodsList: TplGoods,
gridFloor: TplGridFloor,
goodsGrid: TplGoodsGrid,
goodsCard: TplGoods,
bannerFloor: TplGridFloor,
goodsBar: TplGoods,
channelFloor: TplChannelFloor,
recommendFeed: TplRecommendFeed,
// 营销 (1)
// 内容 (3)
titleBar: TplTitle,
noticeBar: TplNotice
};
export function resolveCanvasTemplate(type) {
return CANVAS_TEMPLATE_MAP[type] || null;
}

View File

@@ -1,20 +1,9 @@
### 楼层装修页面
# 店铺楼层装修
#### floorList.vue 楼层列表
- floorList.vue:PC 楼层页面列表。
- renovation.vue:PC 装修入口,加载 pc/index.vue。
- pc/:新版 PC 行布局装修器,包含组件配置、配置面板和画布模板。
- wap/:移动端楼层装修。
- hotzone/:装修图片热区编辑器。
#### renovation.vue 楼层装修主页面
#### singleConfig.vue 右侧配置项
#### modelForm.vue 中间展示所选模块
#### modelFormItem.vue 单个模块样式
#### modelConfig.js 模块数据
#### modelList文件夹为添加的模块
### wap文件夹为移动端楼层装修
### draggable中文文档 (http://www.itxst.com/vue-draggable/tutorial.html)
旧版 PC 装修器及 modelList 组件已移除。

View File

@@ -1,256 +1,7 @@
<template>
<div class="renovation">
<div class="btn-bar">
<el-button type="primary" :loading="submitLoading" @click="saveTemplate">保存模板</el-button>
<el-button class="ml_10" @click="resetTemplate">还原模板</el-button>
<el-button class="ml_10" @click="witeLocalStore">将装修内容写入到本地</el-button>
<el-button class="ml_10" v-if="hasCache" @click="clearCache">清空本地装修缓存</el-button>
</div>
<div class="renovation-main">
<div class="model-list">
<div class="classification-title">基础模块</div>
<draggable
tag="ul"
:list="modelData"
:item-key="getModelKey"
:clone="cloneModel"
:group="{ name: 'model', pull: 'clone', put: false }"
:sort="false"
ghost-class="ghost"
handle=".model-item"
>
<template #item="{ element: model }">
<li class="model-item">
<el-icon><Picture /></el-icon>
<span>{{ model.name }}</span>
</li>
</template>
</draggable>
</div>
<div class="show-content">
<model-form ref="modelFormRef" :data="modelForm"></model-form>
</div>
</div>
</div>
<PcEditor />
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import { Picture } from "@element-plus/icons-vue";
import { modelData } from "./modelConfig";
import Draggable from "vuedraggable";
import ModelForm from "./modelForm.vue";
import * as API_floor from "@/api/other.js";
import { getStore, setStore } from "@/libs/storage";
import { Message, Modal } from "@/utils/message";
const route = useRoute();
const hasCache = ref(false);
const modelForm = ref({ list: [] });
const submitLoading = ref(false);
const modelFormRef = ref(null);
onMounted(() => {
const cache = getStore('managerPCPageCache')
hasCache.value = !!cache;
if(cache){
Modal.confirm({
title: '提示',
content: '获取到本地有缓存数据,是否使用缓存数据?',
okText: '使用',
cancelText: '取消',
onOk: () => {
applyPageData(cache);
}
});
}
getTemplateItem(route.query.id);
});
function getModelKey(model) {
return model.type || model.name;
}
function cloneModel(model) {
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
const cloned = JSON.parse(JSON.stringify(model));
return {
...cloned,
key,
model: cloned.type + "_" + key,
};
}
function clearCache(){
setStore('managerPCPageCache', '')
Message.success('清除成功')
}
function witeLocalStore(){
const data ={...modelForm.value}
data.list.unshift(modelFormRef.value.navList);
data.list.unshift(modelFormRef.value.topAdvert);
setStore('managerPCPageCache', data)
Message.success('写入成功')
}
function saveTemplate() {
submitTemplate(route.query.pageShow ? 'OPEN' : 'CLOSE')
}
function submitTemplate(pageShow) {
submitLoading.value = true
const modelFormData = JSON.parse(JSON.stringify(modelForm.value))
modelFormData.list.unshift(modelFormRef.value.navList);
modelFormData.list.unshift(modelFormRef.value.topAdvert);
const data = {
id: route.query.id,
pageData: JSON.stringify(modelFormData),
pageShow: route.query.pageType === 'SPECIAL' ? 'CLOSE' : pageShow,
pageClientType: 'PC',
};
API_floor.updateHome(route.query.id, data).then((res) => {
submitLoading.value = false
if (res.success) {
Message.success("保存模板成功");
}
});
}
function resetTemplate() {
getTemplateItem(route.query.id);
}
function applyPageData(raw) {
const empty = { list: [] };
if (raw == null || raw === "") {
modelForm.value = empty;
return;
}
let pageData = raw;
if (typeof raw === "string") {
try {
pageData = JSON.parse(raw);
} catch {
modelForm.value = empty;
return;
}
}
const list = Array.isArray(pageData)
? pageData
: Array.isArray(pageData?.list)
? pageData.list
: [];
const form = Array.isArray(pageData) ? { list } : { ...pageData, list };
if (list[0]?.type === "topAdvert") {
modelFormRef.value.topAdvert = list[0];
if (list[1]) {
modelFormRef.value.navList = list[1];
}
modelForm.value = { ...form, list: list.slice(2) };
return;
}
modelForm.value = { ...form, list };
}
function getTemplateItem(id) {
API_floor.getHomeData(id).then((res) => {
if (res.success) {
applyPageData(res.result?.pageData);
}
});
}
watch(modelForm, function (val) {}, { deep: true });
import PcEditor from './pc/index.vue';
</script>
<style lang="scss" scoped>
.renovation {
position: relative;
display: flex;
flex-direction: column;
min-height: 100%;
}
.btn-bar {
position: sticky;
top: 0;
z-index: 99;
background: #fff;
height: 50px;
padding: 10px;
box-shadow: 1px 1px 10px #999;
}
.renovation-main {
display: flex;
align-items: flex-start;
gap: 20px;
flex: 1;
padding-top: 10px;
}
.model-list {
flex-shrink: 0;
width: 130px;
max-height: calc(100vh - 180px);
overflow-y: auto;
padding: 10px;
background: #fff;
position: sticky;
top: 60px;
z-index: 10;
box-shadow: 1px 1px 10px #999;
.classification-title {
width: 100%;
height: 30px;
line-height: 30px;
text-align: center;
}
:deep(ul) {
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.model-item {
width: 110px;
height: 30px;
background: #eee;
margin-top: 10px;
line-height: 30px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: #999;
transition: 0.15s;
border-radius: 4px;
&:hover {
background: $theme_color;
cursor: move;
color: #fff;
}
}
.ghost::after {
border: none;
height: 0;
content: "";
}
}
.show-content {
flex: 1;
min-width: 0;
overflow-x: auto;
}
.ghost {
background: #fff;
height: 30px;
position: relative;
&::after {
content: "松开鼠标添加模块";
position: absolute;
background: #fff;
border: 1px dashed #409eff;
color: #409eff;
top: 0;
left: 0;
width: 100%;
height: 50px;
text-align: center;
line-height: 50px;
}
}
</style>