refactor(decoration): 重构店铺装修组件配置并规范化环境变量

- 重构装修组件配置,新增 icon 与 group 分类字段
- 将组件"图片"重命名为"热区","图片轮播"重命名为"轮播图"
- 新增搜索、公告、视频组件配置项
- 统一为各图片项添加 model 字段标识跳转模式
- 将装修页面组件间 pageGap 间距由 10 调整为 0
- 移除 im 模块中 VUE_APP_COMMON 变量,统一使用 VUE_APP_BUYER
- 为 im 环境变量与配置文件添加详细注释说明
This commit is contained in:
pikachu1995@126.com
2026-10-21 15:17:05 +08:00
parent da95f17885
commit f21ce6e252
118 changed files with 12064 additions and 1275 deletions

View File

@@ -0,0 +1,350 @@
<template>
<div class="panel-ad-cube">
<div class="panel-header">
<span class="panel-name">图片魔方</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">图片设置</div>
<div class="section-tip">当前风格:{{ res.name }},请按布局区域配置对应图片</div>
<div class="img-item" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>图片 {{ index + 1 }}</span>
<span class="img-size" v-if="item.size">建议尺寸 {{ item.size }}</span>
</div>
<div class="img-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-placeholder" v-else>尺寸 {{ item.size || "不限" }}</div>
<div class="field-row" v-if="item.title !== undefined">
<div class="field-label">文字</div>
<el-input v-model="item.title" maxlength="10" />
</div>
<div class="btn-row">
<el-button plain type="primary" @click="pickImage(item)">选择照片</el-button>
<el-button plain type="primary" @click="pickLink(item)">选择链接</el-button>
</div>
<div class="link-view" v-if="item.url && item.url.___type">
已选链接:{{ linkText(item.url) }}
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">风格选择</div>
<div class="section-tip">切换风格将重置当前已配置的图片内容</div>
<div class="style-grid">
<div
v-for="style in styleList"
:key="style.type"
class="style-item"
:class="{ active: res.type === style.type }"
@click="changeStyle(style)"
>
{{ style.name }}
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
import { modelData } from "../config";
// import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
contentGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelAdCube",
hideDecorateTitle: true,
components: { ossManage, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
currentItem: null,
};
},
computed: {
// 图片魔方的可选风格(config 中 drawer 标记的条目)
styleList() {
return modelData.filter((m) => m.drawer);
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
changeStyle(style) {
if (style.type === this.res.type) return;
this.$emit("handleDrawer", style);
},
pickImage(item) {
this.currentItem = item;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url && this.currentItem) {
this.currentItem.img = val.url;
}
},
pickLink(item) {
this.currentItem = item;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-ad-cube {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.img-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-size {
font-size: 12px;
color: #909399;
}
}
}
.img-preview {
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 6px;
margin-bottom: 10px;
img {
width: 100%;
display: block;
border-radius: 4px;
}
}
.img-placeholder {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
color: #909399;
font-size: 12px;
margin-bottom: 10px;
}
.field-row {
display: flex;
align-items: center;
margin-bottom: 10px;
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
}
}
.btn-row {
display: flex;
gap: 10px;
}
.link-view {
margin-top: 8px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.style-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
.style-item {
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 10px 4px;
text-align: center;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #409eff;
color: #409eff;
}
&.active {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="panel-blank">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">高度设置</div>
<slider-field v-model="res.options.height" label="空白高度" :min="1" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
bgColor: "#f5f5f5",
bgColor1: "#f5f5f5",
bgColor2: "#f5f5f5",
height: 10,
paddingX: 0,
pageGap: 0,
};
export default {
name: "PanelBlank",
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS };
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-blank {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<div class="panel-carousel">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">图片设置</div>
<div class="img-item" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>图片 {{ index + 1 }}</span>
<div class="img-item-ops">
<el-icon
:class="{ disabled: index === 0 }"
@click="moveItem(index, -1)"
><Top
/></el-icon>
<el-icon
:class="{ disabled: index === res.options.list.length - 1 }"
@click="moveItem(index, 1)"
><Bottom
/></el-icon>
<el-icon class="del" @click="removeItem(index)"><Delete /></el-icon>
</div>
</div>
<div class="img-item-body">
<div class="img-thumb" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-thumb placeholder" v-else>750*350</div>
<div class="img-item-fields">
<div class="btn-row">
<el-button plain type="primary" @click="pickImage(item)">选择照片</el-button>
<el-button plain type="primary" @click="pickLink(item)">选择链接</el-button>
</div>
<div class="link-view" v-if="item.url && item.url.___type">
已选链接:{{ linkText(item.url) }}
</div>
<div class="link-view empty" v-else>未设置链接</div>
</div>
</div>
</div>
<el-button
v-if="res.options.list.length < 10"
class="add-btn"
plain
type="primary"
@click="addItem"
>添加图片</el-button
>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">轮播风格</div>
<div class="ctrl-row">
<div class="ctrl-label">风格</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in swiperStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.swiperStyle === opt.value }"
@click="res.options.swiperStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="section-tip" v-if="res.options.swiperStyle === 'card'">
卡片风格下,两侧会露出前后图片的一部分
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">指示器设置</div>
<div class="ctrl-row">
<div class="ctrl-label">指示器样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in indicatorStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.indicatorStyle === opt.value }"
@click="res.options.indicatorStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="ctrl-label">指示器位置</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in indicatorPositionOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.indicatorPosition === opt.value }"
@click="res.options.indicatorPosition = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="ctrl-label">色调</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.indicatorColorMode">
<el-radio value="theme">跟随主题风格</el-radio>
<el-radio value="custom">自定义</el-radio>
</el-radio-group>
</div>
</div>
<template
v-if="
res.options.indicatorStyle !== 'none' &&
res.options.indicatorColorMode === 'custom'
"
>
<color-field
v-model="res.options.indicatorColor"
label="指示器颜色"
:default-value="defaults.indicatorColor"
/>
</template>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import { Top, Bottom, Delete } from "@element-plus/icons-vue";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
swiperStyle: "full",
indicatorStyle: "round",
indicatorPosition: "center",
indicatorColorMode: "theme",
indicatorColor: "#409eff",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 7,
};
export default {
name: "PanelCarousel",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
swiperStyleOptions: [
{ value: "full", title: "通栏" },
{ value: "card", title: "卡片" },
],
indicatorStyleOptions: [
{ value: "round", title: "圆形" },
{ value: "line", title: "长条" },
{ value: "none", title: "无" },
],
indicatorPositionOptions: [
{ value: "left", title: "居左" },
{ value: "center", title: "居中" },
{ value: "right", title: "居右" },
],
picModelFlag: false,
currentItem: null,
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list)) {
this.res.options.list = [];
}
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage(item) {
this.currentItem = item;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url && this.currentItem) {
this.currentItem.img = val.url;
}
},
pickLink(item) {
this.currentItem = item;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
moveItem(index, dir) {
const list = this.res.options.list;
const target = index + dir;
if (target < 0 || target >= list.length) return;
list.splice(target, 1, ...list.splice(index, 1, list[target]));
},
removeItem(index) {
this.res.options.list.splice(index, 1);
},
addItem() {
this.res.options.list.push({
img: "",
url: "",
link: "",
size: "750*350",
model: "link",
});
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-carousel {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.img-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-item-ops {
display: flex;
gap: 4px;
.el-icon {
padding: 4px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.del:hover {
color: #e1251b;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
}
}
.img-item-body {
display: flex;
gap: 10px;
}
.img-thumb {
width: 132px;
height: 62px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
border-radius: 4px;
object-fit: cover;
}
&.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
}
}
.img-item-fields {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
.link-view {
margin-top: 0;
}
}
.btn-row {
display: flex;
gap: 10px;
}
.link-view {
margin-top: 8px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="panel-coupon">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">展示设置</div>
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" />
</div>
</div>
<slider-field v-model="firstItem.count" label="展示数量" :min="1" :max="10" />
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">布局设置</div>
<div class="ctrl-row">
<div class="ctrl-label">展示样式</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in layoutOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (res.options.layoutStyle || '1') === opt.value }"
@click="res.options.layoutStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
layoutStyle: "1",
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
contentGap: 5,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "优惠券", count: 5 };
export default {
name: "PanelCoupon",
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
layoutOptions: [
{ value: "1", title: "样式一" },
{ value: "2", title: "样式二" },
{ value: "3", title: "样式三" },
{ value: "4", title: "样式四" },
],
};
},
computed: {
firstItem() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list[0]) {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-coupon {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="panel-flex-one">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">内容设置</div>
<div class="img-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-placeholder" v-else>尺寸不限</div>
<div class="btn-row">
<el-button plain type="primary" @click="pickImage">选择照片</el-button>
<el-button plain type="primary" @click="openHotzone">绘制热区</el-button>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field
v-model="res.options.paddingTop"
label="上边距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.paddingBottom"
label="下边距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="40"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.imgRadius"
label="内容圆角"
:min="0"
:max="30"
/>
</div>
</template>
<hotzone ref="hotzone" @changeZone="changeZone"></hotzone>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import hotzone from "@/views/shop/hotzone";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { ElMessage } from "element-plus";
const DEFAULTS = {
bgColor: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelFlexOne",
hideDecorateTitle: true,
components: { ColorField, SliderField, hotzone, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{ img: "", url: "", link: "", size: "750*280", model: "hotzone" },
];
}
},
methods: {
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.img = val.url;
}
},
openHotzone() {
if (!this.item.img) {
ElMessage.warning("请先选择照片");
return;
}
this.$refs.hotzone.open(this.item);
},
changeZone(zoneInfo) {
this.item.zoneInfo = zoneInfo;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-flex-one {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-preview {
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 6px;
margin-bottom: 10px;
img {
width: 100%;
display: block;
border-radius: 4px;
}
}
.img-placeholder {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
color: #909399;
font-size: 12px;
margin-bottom: 10px;
}
.btn-row {
display: flex;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,619 @@
<template>
<div class="panel-goods">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<!-- 旧版数据提示:保留原渲染,手动升级后才按新版配置 -->
<div v-if="legacy" class="panel-card panel-card-fill">
<div class="section-title">商品列表</div>
<div class="section-tip">
该楼层为旧版商品列表(分类标签 + 绑定商品),当前仍按旧版展示。
升级后可使用展现形式、购物车按钮、商品筛选等新版功能,旧的分类与商品绑定将被清空。
</div>
<el-button type="primary" plain @click="upgradeLegacy">升级为新版</el-button>
</div>
<template v-else-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">展现形式</div>
<div class="ctrl-row">
<div class="ctrl-label">展现形式</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in layoutOptions"
:key="opt.value"
class="seg-item seg-icon"
:class="{ active: firstItem.layout === opt.value }"
@click="changeLayout(opt.value)"
>
<span class="ly-ico" :class="'ly-' + opt.value"><i></i><i></i><i></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">显示内容</div>
<div class="ctrl-row">
<div class="ctrl-label">展示信息</div>
<div class="ctrl-body">
<el-checkbox v-model="firstItem.showName">商品名称</el-checkbox>
<el-checkbox v-model="firstItem.showPrice">商品价格</el-checkbox>
<el-checkbox v-model="firstItem.showSales">已售数量</el-checkbox>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">购物车按钮</div>
<div class="ctrl-row">
<div class="ctrl-label">是否显示</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: firstItem.cartShow !== false }"
@click="firstItem.cartShow = true"
>
显示
</div>
<div
class="seg-item"
:class="{ active: firstItem.cartShow === false }"
@click="firstItem.cartShow = false"
>
隐藏
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="firstItem.cartShow !== false">
<div class="ctrl-label">按钮样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in cartStyleOptions"
:key="opt.value"
class="seg-item seg-icon"
:class="{ active: firstItem.cartStyle === opt.value }"
@click="firstItem.cartStyle = opt.value"
>
<span v-if="opt.value === 'text'" class="cart-text-ico">加购</span>
<span v-else-if="opt.value === 'plus'" class="cart-plus-ico">+</span>
<span v-else class="cart-cart-ico">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path
d="M7 4h-2l-1 2h2l2.4 9.6a2 2 0 0 0 1.9 1.4h7.9a2 2 0 0 0 1.9-1.5l1.6-6.5h-12l-.8-3h11.5l.5-2h-13.4l-.0-.0zM10 20a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"
/>
</svg>
</span>
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="firstItem.cartShow !== false">
<div class="ctrl-label">按钮效果</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: firstItem.cartAction !== 'add' }"
@click="firstItem.cartAction = 'detail'"
>
进入商品详情页
</div>
<div
class="seg-item"
:class="{ active: firstItem.cartAction === 'add' }"
@click="firstItem.cartAction = 'add'"
>
商品加购
</div>
</div>
</div>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">商品设置</div>
<div class="ctrl-row">
<div class="ctrl-label">选择方式</div>
<div class="ctrl-body">
<el-radio-group v-model="firstItem.dataMode">
<el-radio value="category">店铺分类</el-radio>
<el-radio value="goods">指定商品</el-radio>
<el-radio value="brand">商品品牌</el-radio>
</el-radio-group>
</div>
</div>
<template v-if="firstItem.dataMode === 'category'">
<div class="ctrl-row">
<div class="ctrl-label">店铺分类</div>
<div class="ctrl-body">
<el-cascader
v-model="firstItem.categoryId"
:options="categoryTree"
:props="cascaderProps"
placeholder="全部店铺分类"
clearable
/>
</div>
</div>
<slider-field v-model="firstItem.count" label="商品数量" :min="1" :max="50" />
<div class="ctrl-row">
<div class="ctrl-label">商品排序</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in sortOptions"
:key="opt.value"
class="seg-item"
:class="{ active: firstItem.sortType === opt.value }"
@click="firstItem.sortType = opt.value"
>
{{ opt.label }}
</div>
</div>
</div>
</div>
</template>
<template v-else-if="firstItem.dataMode === 'brand'">
<div class="ctrl-row">
<div class="ctrl-label">商品品牌</div>
<div class="ctrl-body">
<el-select v-model="firstItem.brandId" placeholder="全部品牌" clearable filterable>
<el-option v-for="b in brandList" :key="b.id" :label="b.name" :value="b.id" />
</el-select>
</div>
</div>
<slider-field v-model="firstItem.count" label="商品数量" :min="1" :max="50" />
<div class="ctrl-row">
<div class="ctrl-label">商品排序</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in sortOptions"
:key="opt.value"
class="seg-item"
:class="{ active: firstItem.sortType === opt.value }"
@click="firstItem.sortType = opt.value"
>
{{ opt.label }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="goods-wall">
<div class="goods-wall-item" v-for="(g, i) in firstItem.goodsList" :key="g.id">
<img :src="g.thumbnail" alt="" />
<div class="goods-wall-del" @click="firstItem.goodsList.splice(i, 1)">
<el-icon><Close /></el-icon>
</div>
</div>
<div class="goods-wall-add" @click="pickGoods">
<el-icon><Plus /></el-icon>
</div>
</div>
<div class="section-tip">最多选择 50 个商品,展示顺序与选择顺序一致</div>
</template>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.titleColor"
label="商品标题颜色"
:default-value="defaults.titleColor"
/>
<div class="ctrl-row">
<div class="ctrl-label">色调</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: res.options.toneMode !== 'custom' }"
@click="res.options.toneMode = 'theme'"
>
跟随主题风格
</div>
<div
class="seg-item"
:class="{ active: res.options.toneMode === 'custom' }"
@click="res.options.toneMode = 'custom'"
>
自定义
</div>
</div>
</div>
</div>
<color-field
v-if="res.options.toneMode === 'custom'"
v-model="res.options.priceColor"
label="价格颜色"
:default-value="defaults.priceColor"
/>
<color-field
v-model="res.options.salesColor"
label="已售数量颜色"
:default-value="defaults.salesColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="50" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.contentRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialogRef" @selectedGoodsData="onGoodsSelected"></liliDialog>
</div>
</template>
<script>
import { Close, Plus } from "@element-plus/icons-vue";
import GradientField from "../controls/GradientField.vue";
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import { getShopGoodsLabelListSeller, getBrandListData } from "@/api/goods";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
toneMode: "theme",
priceColor: "#FF5C58",
salesColor: "#666666",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 0,
borderRadius: 0,
contentRadius: 7,
};
const DEFAULT_ITEM = {
layout: "list",
showName: true,
showPrice: true,
showSales: true,
cartShow: true,
cartStyle: "plus",
cartAction: "detail",
dataMode: "category",
categoryId: "",
brandId: "",
storeId: "",
goodsList: [],
count: 6,
sortType: "default",
};
export default {
name: "PanelGoods",
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, Close, Plus },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
legacy: false,
layoutOptions: [
{ value: "card", label: "卡片" },
{ value: "list", label: "列表" },
{ value: "three", label: "三列" },
{ value: "big", label: "大图" },
],
sortOptions: [
{ value: "default", label: "综合" },
{ value: "sales", label: "销量" },
{ value: "price", label: "价格" },
],
cascaderProps: {
label: "label",
value: "value",
children: "children",
checkStrictly: true,
emitPath: false,
},
categoryTree: [],
brandList: [],
};
},
computed: {
firstItem() {
return this.res.options.list[0];
},
// 卡片/大图支持文字按钮,两列/三列只有图标按钮
cartStyleOptions() {
const base = [
{ value: "plus", label: "圆形加号" },
{ value: "cart", label: "购物车" },
];
if (this.firstItem.layout === "card" || this.firstItem.layout === "big") {
return [{ value: "text", label: "加购文字" }, ...base];
}
return base;
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list[0]) {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
const item = this.firstItem;
if (item.layout === undefined && Array.isArray(item.titleWay)) {
this.legacy = true;
return;
}
this.fillItemDefaults(item);
this.loadCategories();
this.loadBrands();
},
methods: {
formatShopCategory(list) {
return (list || []).map((item) => ({
value: item.id,
label: item.labelName,
children: item.children && item.children.length ? this.formatShopCategory(item.children) : undefined,
}));
},
loadCategories() {
getShopGoodsLabelListSeller().then((res) => {
if (res && res.success) {
this.categoryTree = this.formatShopCategory(res.result || []);
}
});
},
loadBrands() {
getBrandListData().then((res) => {
this.brandList = Array.isArray(res) ? res : (res && res.result) || [];
});
},
fillItemDefaults(item) {
Object.keys(DEFAULT_ITEM).forEach((key) => {
if (item[key] === undefined) {
item[key] = Array.isArray(DEFAULT_ITEM[key])
? [...DEFAULT_ITEM[key]]
: DEFAULT_ITEM[key];
}
});
},
upgradeLegacy() {
const item = this.firstItem;
delete item.titleWay;
delete item.listWay;
this.fillItemDefaults(item);
this.legacy = false;
this.loadCategories();
this.loadBrands();
},
changeLayout(val) {
this.firstItem.layout = val;
// 两列/三列不支持文字按钮,切布局时兜底
if (
(val === "list" || val === "three") &&
this.firstItem.cartStyle === "text"
) {
this.firstItem.cartStyle = "plus";
}
},
pickGoods() {
const dialog = this.$refs.liliDialogRef;
dialog.clearGoodsSelected();
dialog.goodsFlag = true;
dialog.flag = true;
},
onGoodsSelected(val) {
if (!Array.isArray(val) || !val.length) return;
const list = val.map((item) => ({
id: item.id,
goodsId: item.goodsId,
goodsName: item.goodsName,
price: item.price,
thumbnail: item.thumbnail,
buyCount: item.buyCount,
}));
const exist = new Set(this.firstItem.goodsList.map((g) => g.id));
const merged = this.firstItem.goodsList.concat(
list.filter((g) => !exist.has(g.id))
);
this.firstItem.goodsList = merged.slice(0, 50);
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.seg-icon {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 6px 10px;
}
// 展现形式小图标
.ly-ico {
display: inline-flex;
align-items: center;
gap: 2px;
height: 14px;
i {
display: block;
background: currentColor;
border-radius: 1px;
}
}
.ly-card {
i:first-child {
width: 8px;
height: 10px;
}
i:not(:first-child) {
width: 12px;
height: 2px;
}
flex-wrap: wrap;
width: 24px;
align-content: center;
}
.ly-list {
i {
width: 8px;
height: 10px;
&:nth-child(3) {
display: none;
}
}
}
.ly-three {
i {
width: 6px;
height: 10px;
}
}
.ly-big {
i:first-child {
width: 22px;
height: 10px;
}
i:not(:first-child) {
display: none;
}
}
// 购物车按钮样式小图标
.cart-text-ico {
display: inline-block;
padding: 1px 8px;
border-radius: 8px;
background: #c0c4cc;
color: #fff;
font-size: 11px;
line-height: 14px;
}
.cart-plus-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #c0c4cc;
color: #fff;
font-size: 12px;
line-height: 1;
}
.seg-item.active .cart-text-ico,
.seg-item.active .cart-plus-ico {
background: #409eff;
}
.cart-cart-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.goods-wall {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.goods-wall-item {
position: relative;
width: 72px;
height: 72px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #ebeef5;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.goods-wall-del {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
}
}
.goods-wall-add {
width: 72px;
height: 72px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 22px;
cursor: pointer;
&:hover {
border-color: #409eff;
color: #409eff;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="panel-line">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.lineColor"
label="线条颜色"
:default-value="defaults.lineColor"
/>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">样式设置</div>
<segment-field
v-model="res.options.lineStyle"
label="线条样式"
:options="lineStyleOptions"
/>
<slider-field v-model="res.options.lineHeight" label="线条高度" :min="1" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import SegmentField from "../controls/SegmentField.vue";
const DEFAULTS = {
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
lineColor: "#979797",
lineStyle: "dashed",
lineHeight: 1,
paddingX: 0,
pageGap: 0,
};
export default {
name: "PanelLine",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, SegmentField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
lineStyleOptions: [
{ label: "虚线", value: "dashed" },
{ label: "实线", value: "solid" },
{ label: "点状线", value: "dotted" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-line {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<div class="panel-notice">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">图标设置</div>
<div class="section-tip">支持上传 1 张图片,建议宽度 130*36px</div>
<div class="icon-row">
<div class="icon-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="icon-placeholder" v-else>图标</div>
<el-button plain type="primary" @click="pickImage">上传图标</el-button>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">公告设置</div>
<div class="section-tip">最多可添加 10 个版块</div>
<div class="notice-item" v-for="(notice, index) in item.title" :key="index">
<div class="notice-item-head">
<span>版块 {{ index + 1 }}</span>
<div class="img-item-ops">
<el-icon :class="{ disabled: index === 0 }" @click="moveItem(index, -1)"><Top /></el-icon>
<el-icon
:class="{ disabled: index === item.title.length - 1 }"
@click="moveItem(index, 1)"
><Bottom
/></el-icon>
<el-icon class="del" @click="removeItem(index)"><Delete /></el-icon>
</div>
</div>
<div class="field-row">
<div class="field-label">标题</div>
<el-input v-model="notice.context" maxlength="50" placeholder="请输入公告内容" />
</div>
<div class="field-row">
<div class="field-label">链接</div>
<div class="link-text" v-if="notice.url && notice.url.___type">{{ linkText(notice.url) }}</div>
<el-button plain type="primary" @click="pickLink(notice)">选择链接</el-button>
</div>
</div>
<el-button
v-if="item.title.length < 10"
class="add-btn"
plain
type="primary"
@click="addItem"
>+ 添加模块</el-button
>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文字设置</div>
<div class="ctrl-row">
<div class="ctrl-label">滚动方向</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: item.direction !== 'horizontal' }"
@click="item.direction = 'vertical'"
>
上下
</div>
<div
class="seg-item"
:class="{ active: item.direction === 'horizontal' }"
@click="item.direction = 'horizontal'"
>
左右
</div>
</div>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">文本位置</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in alignOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (item.textAlign || 'left') === opt.value }"
@click="item.textAlign = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<color-field
v-model="item.color"
label="文字颜色"
:default-value="defaults.itemColor"
/>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import { Top, Bottom, Delete } from "@element-plus/icons-vue";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 0,
paddingBottom: 0,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
itemColor: "#333333",
};
export default {
name: "PanelNotice",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
alignOptions: [
{ value: "left", title: "居左" },
{ value: "center", title: "居中" },
{ value: "right", title: "居右" },
],
picModelFlag: false,
currentItem: null,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [{ title: [], color: "#333333", direction: "vertical", img: "" }];
}
const item = this.item;
if (!Array.isArray(item.title) || !item.title.length) {
item.title = [{ context: "这是一条公告", url: "" }];
}
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.img = val.url;
}
},
pickLink(notice) {
this.currentItem = notice;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
moveItem(index, dir) {
const list = this.item.title;
const target = index + dir;
if (target < 0 || target >= list.length) return;
list.splice(target, 1, ...list.splice(index, 1, list[target]));
},
removeItem(index) {
this.item.title.splice(index, 1);
},
addItem() {
this.item.title.push({ context: "", url: "" });
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-notice {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.icon-row {
display: flex;
align-items: center;
gap: 10px;
}
.icon-preview {
width: 88px;
height: 32px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
}
.icon-placeholder {
width: 88px;
height: 32px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
}
.notice-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.notice-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
}
}
.img-item-ops {
display: flex;
gap: 4px;
.el-icon {
padding: 4px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.del:hover {
color: #e1251b;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
.field-row {
display: flex;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
width: 32px;
}
.el-input {
flex: 1;
min-width: 0;
}
.el-button {
flex-shrink: 0;
}
}
.link-text {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="panel-page-setting">
<div class="panel-header">
<span class="panel-name">页面设置</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">基础设置</div>
<div class="ctrl-row">
<div class="ctrl-label"><span class="required-star">*</span>模板名称</div>
<div class="ctrl-body">
<el-input v-model="res.name" maxlength="30" placeholder="请输入模板名称" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label"><span class="required-star">*</span>页面标题</div>
<div class="ctrl-body">
<el-input v-model="res.title" maxlength="30" placeholder="请输入页面标题" />
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card panel-card-fill">
<div class="section-title">颜色设置</div>
<segment-field v-model="res.bgType" label="背景设置" :options="bgTypeOptions" />
<color-field
v-if="res.bgType === 'color'"
v-model="res.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<template v-else>
<div class="ctrl-row">
<div class="ctrl-label">背景图片</div>
<div class="ctrl-body bg-image-body">
<div class="bg-image-preview" v-if="res.bgImage">
<img :src="res.bgImage" alt="" />
<el-icon class="bg-image-clear" @click="clearImage"><Close /></el-icon>
</div>
<el-button plain type="primary" @click="pickImage">上传图片</el-button>
</div>
</div>
<div class="section-tip upload-tip">上传图片 建议:宽度750px</div>
<segment-field v-model="res.bgFill" label="填充方式" :options="bgFillOptions" />
</template>
<color-field
v-model="res.topColor"
label="顶部颜色"
:default-value="defaults.topColor"
/>
<segment-field v-model="res.titleColor" label="标题颜色" :options="titleColorOptions" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SegmentField from "../controls/SegmentField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { Close } from "@element-plus/icons-vue";
const DEFAULTS = {
name: "",
title: "",
bgType: "color",
bgColor: "#F5F5F5",
bgImage: "",
bgFill: "stretch",
topColor: "#ffffff",
titleColor: "black",
};
export default {
name: "PanelPageSetting",
components: { ColorField, SegmentField, ossManage, Close },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
bgTypeOptions: [
{ label: "背景色", value: "color" },
{ label: "背景图", value: "image" },
],
bgFillOptions: [
{ label: "拉伸", value: "stretch" },
{ label: "缩放", value: "contain" },
{ label: "填充", value: "cover" },
],
titleColorOptions: [
{ label: "白色", value: "white" },
{ label: "黑色", value: "black" },
],
picModelFlag: false,
};
},
created() {
Object.keys(DEFAULTS).forEach((key) => {
if (this.res[key] === undefined) {
this.res[key] = DEFAULTS[key];
}
});
},
methods: {
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.res.bgImage = val.url;
}
},
clearImage() {
this.res.bgImage = "";
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-page-setting {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.required-star {
color: #f56c6c;
margin-right: 2px;
}
.bg-image-body {
align-items: center;
}
.bg-image-preview {
position: relative;
width: 88px;
height: 48px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.bg-image-clear {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #909399;
color: #ffffff;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f56c6c;
}
}
}
.upload-tip {
margin: -8px 0 12px 102px;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="panel-rich-text">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">富文本内容</div>
<editor v-model="res.options.content" height="300px" />
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import editor from "@/views/lili-components/editor";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
content: "",
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingX: 12,
pageGap: 0,
borderRadius: 0,
};
export default {
name: "PanelRichText",
hideDecorateTitle: true,
components: { editor, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-rich-text {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="panel-search">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">提示文字设置</div>
<div class="field-row">
<div class="field-label">提示文字</div>
<el-input
v-model="res.options.list[0].title"
size="default"
maxlength="20"
placeholder="搜索商品名称"
/>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">搜索热词</div>
<div class="hot-word" v-for="(word, index) in res.options.hotWords" :key="index">
<el-input
v-model="res.options.hotWords[index]"
size="default"
maxlength="10"
placeholder="选填,不超过十个字"
/>
<el-icon class="del" @click="removeWord(index)"><CircleClose /></el-icon>
</div>
<el-button class="add-btn" plain type="primary" @click="addWord">
+ 添加热词
</el-button>
<div class="field-row interval-row">
<div class="field-label">切换时间</div>
<el-input-number
class="interval-input"
v-model="res.options.hotInterval"
:min="1"
:max="60"
size="default"
controls-position="right"
/>
<span class="unit">秒</span>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文本位置</div>
<div class="ctrl-row">
<div class="ctrl-label">文本位置</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: res.options.textAlign !== 'center' }"
@click="res.options.textAlign = 'left'"
>
居左
</div>
<div
class="seg-item"
:class="{ active: res.options.textAlign === 'center' }"
@click="res.options.textAlign = 'center'"
>
居中
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.boxColor"
label="框体颜色"
:default-value="defaults.boxColor"
/>
<color-field
v-model="res.options.textColor"
label="输入文字颜色"
:default-value="defaults.textColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="20" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.boxRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import { CircleClose } from "@element-plus/icons-vue";
const DEFAULTS = {
textAlign: "left",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
boxColor: "#EDEDED",
textColor: "#999999",
paddingX: 12,
borderRadius: 0,
boxRadius: 15,
hotWords: [],
hotInterval: 3,
};
export default {
name: "PanelSearch",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, CircleClose },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [{ title: "搜索" }];
}
if (!Array.isArray(this.res.options.hotWords)) {
this.res.options.hotWords = [];
}
},
methods: {
addWord() {
this.res.options.hotWords.push("");
},
removeWord(index) {
this.res.options.hotWords.splice(index, 1);
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-search {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.field-row {
display: flex;
align-items: center;
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
}
}
.hot-word {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.del {
cursor: pointer;
color: #909399;
font-size: 18px;
flex-shrink: 0;
&:hover {
color: #e1251b;
}
}
}
.add-btn {
width: 100%;
margin-bottom: 14px;
}
.interval-row {
.interval-input {
flex: 1;
min-width: 0;
:deep(.el-input__inner) {
text-align: center;
}
}
.unit {
margin-left: 8px;
font-size: 13px;
color: #909399;
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="panel-title">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">文本标题</div>
<div class="ctrl-row">
<div class="ctrl-label">标题名称</div>
<div class="ctrl-body">
<el-input v-model="item.title" maxlength="20" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">副标题</div>
<div class="ctrl-body">
<el-input v-model="item.title1" maxlength="20" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">右侧按钮</div>
<div class="ctrl-body">
<el-radio-group v-model="item.showMore">
<el-radio :value="true">显示</el-radio>
<el-radio :value="false">隐藏</el-radio>
</el-radio-group>
</div>
</div>
<template v-if="item.showMore">
<div class="ctrl-row">
<div class="ctrl-label">右侧文字</div>
<div class="ctrl-body">
<el-input v-model="item.moreText" maxlength="6" placeholder="更多" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">链接</div>
<div class="ctrl-body">
<span class="link-text" v-if="item.moreUrl && item.moreUrl.___type">
{{ linkText(item.moreUrl) }}
</span>
<el-button plain type="primary" @click="pickLink">选择链接</el-button>
</div>
</div>
</template>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">选择风格</div>
<div class="ctrl-row">
<div class="ctrl-label">风格</div>
<div class="ctrl-body">
<el-radio-group v-model="item.bgType">
<el-radio value="image">背景图片</el-radio>
<el-radio value="color">背景色</el-radio>
</el-radio-group>
</div>
</div>
<div class="ctrl-row" v-if="item.bgType === 'color'">
<div class="ctrl-label">背景色</div>
<div class="ctrl-body">
<el-color-picker v-model="item.bk_color" />
<el-input v-model="item.bk_color" class="ctrl-color-input" />
</div>
</div>
<div class="ctrl-row" v-else>
<div class="ctrl-label">上传背景图</div>
<div class="ctrl-body">
<div class="bg-preview" v-if="item.bgImage">
<img :src="item.bgImage" alt="" />
</div>
<el-button plain type="primary" @click="pickImage">上传背景图</el-button>
</div>
</div>
<div class="section-tip" v-if="item.bgType !== 'color'">建议 910px*86px</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文字设置</div>
<color-field v-model="item.color" label="标题颜色" :default-value="defaults.color" />
<slider-field v-model="item.fontSize" label="标题文字" :min="12" :max="40" />
<div class="ctrl-row">
<div class="ctrl-label">文本样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in textStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (item.textStyle || 'normal') === opt.value }"
@click="item.textStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<color-field v-model="item.color1" label="副标题颜色" :default-value="defaults.color1" />
<slider-field v-model="item.subFontSize" label="副标题文字" :min="10" :max="30" />
<template v-if="item.showMore">
<color-field v-model="item.moreColor" label="按钮颜色" :default-value="defaults.moreColor" />
<slider-field v-model="item.moreFontSize" label="按钮文字" :min="10" :max="24" />
</template>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.radiusTop" label="上圆角" :min="0" :max="30" />
<slider-field v-model="res.options.radiusBottom" label="下圆角" :min="0" :max="30" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
radiusTop: 0,
radiusBottom: 0,
color: "#282828",
color1: "#282828",
moreColor: "#282828",
};
export default {
name: "PanelTitle",
hideDecorateTitle: true,
components: { ColorField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
textStyleOptions: [
{ value: "normal", title: "正常" },
{ value: "italic", title: "斜体" },
{ value: "bold", title: "加粗" },
],
picModelFlag: false,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{
title: "标题",
title1: "副标题",
color: "#282828",
color1: "#282828",
bk_color: "#FFFFFF",
textAlign: "left",
},
];
}
const item = this.item;
if (item.showMore === undefined) item.showMore = true;
if (!item.moreText) item.moreText = "更多";
if (!item.bgType) item.bgType = "color";
if (!item.fontSize) item.fontSize = 20;
if (!item.subFontSize) item.subFontSize = 14;
if (!item.moreFontSize) item.moreFontSize = 12;
if (!item.moreColor) item.moreColor = "#282828";
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.bgImage = val.url;
}
},
pickLink() {
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.item.moreUrl = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-title {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.link-text {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 10px;
}
.bg-preview {
width: 132px;
height: 30px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 2px;
box-sizing: border-box;
margin-right: 10px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="panel-video">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">视频内容设置</div>
<div class="ctrl-row">
<div class="ctrl-label">上传视频</div>
<div class="ctrl-body">
<div class="upload-state" :class="{ filled: item.videoUrl }">
{{ item.videoUrl ? "已选择视频" : "未选择视频" }}
</div>
<el-button plain type="primary" @click="pickFile('videoUrl')">
{{ item.videoUrl ? "重新选择" : "上传视频" }}
</el-button>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">视频封面</div>
<div class="ctrl-body">
<div class="cover-preview" v-if="item.poster">
<img :src="item.poster" alt="" />
</div>
<div class="cover-placeholder" v-else>封面</div>
<el-button plain type="primary" @click="pickFile('poster')">
{{ item.poster ? "重新选择" : "上传封面" }}
</el-button>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">自动播放</div>
<div class="ctrl-body">
<el-switch v-model="item.autoplay" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">画面比例</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in aspectOptions"
:key="opt"
class="seg-item"
:class="{ active: (item.aspectRatio || '16:9') === opt }"
@click="item.aspectRatio = opt"
>
{{ opt }}
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<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="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelVideo",
hideDecorateTitle: true,
components: { GradientField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
aspectOptions: ["16:9", "4:3", "1:1"],
picModelFlag: false,
currentField: "videoUrl",
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{ videoUrl: "", poster: "", autoplay: false, aspectRatio: "16:9" },
];
}
},
methods: {
pickFile(field) {
this.currentField = field;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = field === "videoUrl" ? "video" : "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item[this.currentField] = val.url;
}
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-video {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.upload-state {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.filled {
color: #409eff;
}
}
.cover-preview {
width: 88px;
height: 44px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: 4px;
}
}
.cover-placeholder {
width: 88px;
height: 44px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.seg {
display: inline-flex;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
.seg-item {
padding: 6px 14px;
font-size: 13px;
color: #606266;
cursor: pointer;
border-left: 1px solid #dcdfe6;
transition: all 0.2s ease;
&:first-child {
border-left: none;
}
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
// 店铺端组件配置面板映射:type → 面板组件
// 面板契约:props res(整个楼层条目),直接修改 res.options.*,保存链路自动感知
import PanelBlank from "./PanelBlank.vue";
import PanelLine from "./PanelLine.vue";
import PanelRichText from "./PanelRichText.vue";
import PanelCoupon from "./PanelCoupon.vue";
import PanelFlexOne from "./PanelFlexOne.vue";
import PanelCarousel from "./PanelCarousel.vue";
import PanelAdCube from "./PanelAdCube.vue";
import PanelSearch from "./PanelSearch.vue";
import PanelNotice from "./PanelNotice.vue";
import PanelVideo from "./PanelVideo.vue";
import PanelTitle from "./PanelTitle.vue";
import PanelGoods from "./PanelGoods.vue";
// 图片魔方及其全部可选风格共用 PanelAdCube
const adCubeTypes = [
"tpl_ad_list",
"leftOneRightTwo",
"leftTwoRightOne",
"flexThree",
"flexTwo",
"topOneBottomTwo",
"topTwoBottomOne",
"flexFive",
"flexFour",
"textPicture",
];
export default {
blank: PanelBlank,
line: PanelLine,
richText: PanelRichText,
coupon: PanelCoupon,
flexOne: PanelFlexOne,
carousel: PanelCarousel,
search: PanelSearch,
notice: PanelNotice,
video: PanelVideo,
title: PanelTitle,
goods: PanelGoods,
...Object.fromEntries(adCubeTypes.map((t) => [t, PanelAdCube])),
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-color-picker
:model-value="modelValue"
@update:model-value="onInput"
/>
<el-input
:model-value="modelValue"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="onInput"
/>
<el-button link type="primary" @click="onReset">重置</el-button>
</div>
</div>
</template>
<script>
export default {
name: "ColorField",
props: {
modelValue: { type: String, default: "" },
defaultValue: { type: String, default: "" },
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
onReset() {
this.$emit("update:modelValue", this.defaultValue);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body gradient-body">
<div class="gradient-row">
<el-color-picker
:model-value="start"
@update:model-value="$emit('update:start', $event)"
/>
<el-input
:model-value="start"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="$emit('update:start', $event)"
/>
<el-button link type="primary" @click="$emit('update:start', defaultStart)">重置</el-button>
</div>
<div class="gradient-row">
<el-color-picker
:model-value="end"
@update:model-value="$emit('update:end', $event)"
/>
<el-input
:model-value="end"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="$emit('update:end', $event)"
/>
<el-button link type="primary" @click="$emit('update:end', defaultEnd)">重置</el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "GradientField",
props: {
start: { type: String, default: "" },
end: { type: String, default: "" },
defaultStart: { type: String, default: "" },
defaultEnd: { type: String, default: "" },
label: { type: String, default: "背景颜色" },
},
emits: ["update:start", "update:end"],
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
.gradient-body {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.gradient-row {
display: flex;
align-items: center;
gap: 8px;
position: relative;
// 两个色板之间的连接线(对齐色板中心)
& + .gradient-row::before {
content: "";
position: absolute;
left: 15px;
top: -14px;
width: 2px;
height: 10px;
background: #dcdfe6;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-radio-group
:model-value="modelValue"
@update:model-value="onInput"
>
<el-radio-button
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</el-radio-button
>
</el-radio-group>
</div>
</div>
</template>
<script>
export default {
name: "SegmentField",
props: {
modelValue: { type: String, default: "" },
options: {
type: Array,
default: () => [],
},
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-slider
class="ctrl-slider"
:model-value="modelValue"
:min="min"
:max="max"
@update:model-value="onInput"
/>
<el-input-number
class="ctrl-number"
:model-value="modelValue"
:min="min"
:max="max"
@update:model-value="onInput"
/>
</div>
</div>
</template>
<script>
export default {
name: "SliderField",
props: {
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
.ctrl-body {
flex-wrap: nowrap;
}
.ctrl-slider {
flex: 1;
min-width: 0;
}
.ctrl-number {
width: 130px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,28 @@
.ctrl-row {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
gap: 12px;
}
.ctrl-label {
width: 90px;
flex-shrink: 0;
line-height: 32px;
color: #606266;
font-size: 14px;
white-space: nowrap;
}
.ctrl-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ctrl-color-input {
width: 110px;
}

View File

@@ -0,0 +1,179 @@
// 新配置面板共用的骨架样式(灰底 + 白色卡片 + 头部 tabs)
// 使用方式:面板根类名需与 .panel-root 一起用,或在面板 scoped 样式里 @import 后把根类加上
.panel-search,
.panel-promotion,
.panel-coupon,
.panel-title,
.panel-shop,
.panel-goods,
.panel-notice,
.panel-video,
.panel-article,
.panel-page-setting {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.panel-card-fill {
flex: 1;
margin-bottom: 0;
}
// 面板根容器改为纵向 flex,让最后一张卡片可撑满到底
.panel-flex-one,
.panel-carousel,
.panel-ad-cube,
.panel-menu,
.panel-search,
.panel-notice,
.panel-video,
.panel-title,
.panel-integral,
.panel-coupon,
.panel-article,
.panel-shop,
.panel-goods,
.panel-blank,
.panel-line,
.panel-rich-text,
.panel-promotion,
.panel-page-setting {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
.setting-label {
font-size: 14px;
color: #606266;
.setting-value {
margin-left: 8px;
color: #303133;
}
}
}
.seg {
display: inline-flex;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
.seg-item {
padding: 6px 14px;
font-size: 13px;
color: #606266;
cursor: pointer;
border-left: 1px solid #dcdfe6;
transition: all 0.2s ease;
&:first-child {
border-left: none;
}
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
// 选项平铺选择器:一行两个,超出换行
.layout-seg {
display: flex;
flex-wrap: wrap;
width: 100%;
.seg-item {
flex: 1 0 40%;
padding: 6px 0;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
&:nth-child(3) {
border-left: none;
}
&:nth-child(n + 3) {
border-top: 1px solid #dcdfe6;
}
}
&.layout-seg-3 {
flex-wrap: nowrap;
.seg-item {
flex: 1 1 0;
&:nth-child(3) {
border-left: 1px solid #dcdfe6;
}
&:nth-child(n + 3) {
border-top: none;
}
}
}
}
// 选项平铺选择器(独立用法):一行三个
.layout-seg-3 {
display: flex;
flex-wrap: nowrap;
width: 100%;
.seg-item {
flex: 1 1 0;
padding: 6px 0;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
}
}

View File

@@ -1,13 +1,13 @@
:deep(.el-overlay) {
z-index: 2000;
}
.decorate-view-link{
font-size: 12px;
margin: 0 4px;
flex: 1;
min-width: 0;
color: #999;
.decorate-view-link {
font-size: 12px;
margin: 0 4px;
flex: 1;
min-width: 0;
color: #999;
}
.decorate-view-btn {
flex-shrink: 0;
@@ -22,111 +22,179 @@
margin: 0;
}
}
.decorate-view-style {
border: 1px solid #ededed;
background: #f7f7fa;
text-align: center;
width: 100%;
padding: 30px 0 0 0;
margin-bottom: 20px;
cursor: pointer;
> .select-style {
background: #ededed;
padding: 15px 0;
font-size: 15px;
}
}
.decorate-border {
border: 1px solid #ededed;
margin: 10px 0;
padding: 0 10px;
}
.drawer {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 14px;
> .drawer-item {
cursor: pointer;
border: 1px solid #ededed;
background: #f9f0ff;
width: calc(50% - 7px);
box-sizing: border-box;
color: #9254de;
border-radius: 0.8em;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
}
.hidden-input {
display: none;
}
.decorate {
padding: 0 20px;
height: calc(100vh - 120px);
overflow-y: auto;
padding-bottom: 120px;
}
.decorate-title {
height: 50px;
line-height: 50px;
font-weight: bold;
}
.decorate-list {
overflow: hidden;
}
.decorate-view {
display: flex;
margin: 20px 0;
align-items: center;
}
.decorate-item-box {
background: #fff;
padding: 10px;
border: 1px solid #ededed;
}
.decorate-item-title {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
padding: 0 10px;
}
.decorate-view-title {
margin-right: 10px;
}
.decorate-item {
border: 1px solid #ededed;
background: #f7f7fa;
text-align: center;
width: 100%;
padding: 30px 0 0 0;
margin-bottom: 20px;
cursor: pointer;
>.select-style {
background: #ededed;
border-top-left-radius: 0.4em;
margin-bottom: 20px;
border-top-right-radius: 0.4em;
padding: 15px 0;
font-size: 15px;
}
.show-image {
max-width: 50px;
}
.tips {
font-size: 12px;
color: #999;
> span {
color: $theme_color;
}
}
.selectBtn {
margin-left: 10px;
}
.bing-goods-list {
}
.decorate-border {
border: 1px solid #ededed;
margin: 10px 0;
padding: 0 10px;
}
.drawer {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 14px;
>.drawer-item {
cursor: pointer;
border: 1px solid #ededed;
background: #f9f0ff;
width: calc(50% - 7px);
box-sizing: border-box;
color: #9254de;
border-radius: 0.8em;
height: 60px;
display: flex;
> .bing-goods-item {
width: 50px;
height: 50px;
> img {
width: 100%;
height: 100%;
display: block;
}
align-items: center;
flex-direction: column;
justify-content: center;
}
}
.hidden-input {
display: none;
}
.decorate {
padding: 0 20px;
height: calc(100vh - 96px);
overflow-y: auto;
background: #f5f7fa;
}
.decorate-title {
height: 50px;
line-height: 50px;
font-weight: bold;
}
.decorate-list {
overflow: hidden;
}
.decorate-view {
display: flex;
margin: 12px 0;
align-items: center;
}
.decorate-item-box {
background: #fff;
padding: 10px;
border: 1px solid #ededed;
}
.decorate-item-title {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
padding: 0 10px;
}
.decorate-view-title {
margin-right: 10px;
}
.decorate-item {
background: #ededed;
border-top-left-radius: 0.4em;
margin-bottom: 20px;
border-top-right-radius: 0.4em;
}
.show-image {
max-width: 50px;
}
.tips {
font-size: 12px;
color: #999;
>span {
color: $theme_color;
}
}
.selectBtn {
margin-left: 10px;
}
.bing-goods-list {
display: flex;
>.bing-goods-item {
width: 50px;
height: 50px;
>img {
width: 100%;
height: 100%;
display: block;
}
}
}
.align-text {
width: 30px;
height: 30px;
display: block;
}
.card-box {
display: flex;
}
.decorate-notice {
margin-top: 10px;
}
.card {}
.card:hover {
background: #ededed;
}
.active {
background: #ededed;
}
.title-item{
border-bottom: 1px solid #ededed;
padding:10px 10px 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
>.title-btn{
display: flex;
}
}
.title-goodsName{
/* 适用于webkit内核和移动端 */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.title-tooltip{
white-space: normal;
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="decorate">
<div class="decorate-title">
<div class="decorate-title" v-if="!hidePanelTitle">
{{ res.name }}
<el-button
@@ -20,14 +20,24 @@
style="margin-left: 20px"
size="small"
plain
v-if="res.type == 'promotions' || res.drawerPromotions"
v-if="res.drawerPromotions"
type="primary"
@click="selectPromotions()"
>选择促销活动</el-button
>
</div>
<!-- 新组件独立配置面板(按 type 分发) -->
<component
v-if="panelComponent"
:is="panelComponent"
:res="res"
@handleDrawer="emit('handleDrawer', $event)"
/>
<!-- 老组件遗留配置分支 -->
<template v-else>
<el-alert
v-if="res.name == '商品分类'"
v-if="res.name == '商品列表'"
type="warning"
title="装修提示"
:closable="false"
@@ -95,9 +105,7 @@
<div
class="decorate-list"
v-if="
(res.type != 'tpl_ad_list' &&
res.type != 'tpl_activity_list' &&
res.type != 'promotions') ||
(res.type != 'tpl_ad_list' && res.type != 'tpl_activity_list') ||
res.drawer ||
res.drawerPromotions
"
@@ -107,7 +115,7 @@
v-for="(item, index) in res.options.list"
:key="index"
>
<div class="decorate-item-title">
<div class="decorate-item-title" v-if="res.type != 'video'">
<div>卡片</div>
<el-icon
v-if="res.close || res.type == 'promotionDetail'"
@@ -247,7 +255,7 @@
@click="changeTextAlign('left')"
>
<img
:src="alignTextLeftImg"
:src="alignTextLeft"
class="align-text"
alt=""
/>
@@ -258,7 +266,7 @@
@click="changeTextAlign('center')"
>
<img
:src="alignTextCenterImg"
:src="alignTextCenter"
class="align-text"
alt=""
/>
@@ -269,7 +277,7 @@
@click="changeTextAlign('right')"
>
<img
:src="alignTextRightImg"
:src="alignTextRight"
class="align-text"
alt=""
/>
@@ -373,21 +381,17 @@
</div>
</div>
<!-- 视频楼层配置 -->
<video-config
v-if="res.type == 'video'"
:item="item"
@select-file="(field) => handleClickFile(item, index, field)"
/>
<!-- 填写链接 -->
<div class="decorate-view" v-if="res.onlyImg">
<div class="decorate-view-title">选择模式</div>
<div>
<el-radio-group v-model="item.model">
<el-radio value="link">链接</el-radio>
<el-radio value="hotzone">热区</el-radio>
</el-radio-group>
</div>
</div>
<div class="decorate-view" v-if="!res.notLink">
<div class="decorate-view-title">选择链接</div>
<div class="decorate-view-title">{{ item.model === "hotzone" ? "热区" : "选择链接" }}</div>
<div
v-if="item.url && item.url.length != 0"
class="decorate-view-link"
@@ -493,7 +497,7 @@
>
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -520,26 +524,36 @@
<hotzone ref="hotzoneRef" @changeZone="changeZone"></hotzone>
<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" />
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</template>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from "vue";
import { ref, watch, nextTick, computed } from "vue";
import { CircleClose } from "@element-plus/icons-vue";
import { Message } from "@/utils/message";
import alignTextLeftImg from "@/assets/align-text-left.png";
import alignTextCenterImg from "@/assets/align-text-center.png";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { modelData } from "./config";
import hotzone from "@/views/shop/hotzone";
import ways from "@/views/lili-dialog/wap.js";
import categoryTemplate from "@/views/lili-dialog/template/category";
import VideoConfig from "./video-config";
import panelMap from "./config-panel";
import alignTextLeft from "@/assets/align-text-left.png";
import alignTextCenter from "@/assets/align-text-center.png";
import alignTextRight from "@/assets/align-text-right.png";
const props = defineProps(["res"]);
const emit = defineEmits(["handleDrawer"]);
// alignTextLeftImg/alignTextCenterImg、ways(选择链接的类型)、modelData(装修数据) 为 import 常量,模板中直接使用
// alignTextLeft/alignTextCenter/alignTextRight、ways(选择链接的类型)、modelData(装修数据) 为 import 常量,模板中直接使用
const picModelFlag = ref(false); //图片选择器
const linkType = ref("goods"); // dialog弹窗口类型
const styleFlag = ref(false); //广告魔方开关
@@ -547,14 +561,26 @@ const textAlign = ref(
props.res.options.list[0] ? props.res.options.list[0].textAlign : "center"
); //文字对齐方式
const promotionsFlag = ref(false); //广告魔方开关
const selectedLinkIndex = ref(""); //选择链接的索引
const selectedGoods = ref(""); // 已选商品
const selectedLinks = ref(""); // 已选链接
const modelList = ref(""); // 装修列表
const enableSelectCategory = ref(false); //商品是否绑定分类
const pendingCategory = ref(null); // 选择分类弹窗草稿
const goodsSelectedIndex = ref(0); //绑定商品分类的索引
const pictureField = ref("img"); //图片选择器回写的字段
// 新组件按 type 分发到独立配置面板;老组件走下方遗留分支
const panelComponent = computed(
() => (props.res && panelMap[props.res.type]) || null
);
// 面板声明 hideDecorateTitle 时,标题行由面板自己渲染
const hidePanelTitle = computed(
() => !!(panelComponent.value && panelComponent.value.hideDecorateTitle)
);
// hotzoneRef/ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const hotzoneRef = ref(null);
const ossManageRef = ref(null);
@@ -722,9 +748,9 @@ function clickDrawer(item, index) {
}
// 打开图片选择器
function liliDialogFlag(flag) {
liliDialog.value.clearGoodsSelected();
liliDialog.value.goodsFlag = flag;
liliDialog.value.flag = true;
liliDialogRef.value.clearGoodsSelected();
liliDialogRef.value.goodsFlag = flag;
liliDialogRef.value.flag = true;
}
function changeTextAlign(val) {
@@ -743,9 +769,24 @@ function clickLink(val, index, oval) {
liliDialogFlag(false);
}
}
function addZone(zoneInfo) {
selectedLinks.value.zoneInfo.push(zoneInfo);
}
function changeZone(zoneInfo) {
selectedLinks.value.zoneInfo = zoneInfo;
}
//点击图片解析成base64
function changeFile(item, index) {
const file = document.getElementById("files" + index).files[0];
if (file == void 0) return false;
const reader = new FileReader();
reader.readAsDataURL(file);
nextTick((res) => {
reader.onload = (e) => {
item.img = e.target.result;
};
});
}
//添加设置
function addDecorate(type, data) {
if (type === "notice") {
@@ -768,20 +809,43 @@ function addDecorate(type, data) {
props.res.options.list.push(way);
}
}
function addHotPoint() {
let way = {
img: "https://picsum.photos/id/264/200/200",
text: "标题",
link: "",
url: "",
};
props.res.options.list[0].points.push(way);
}
// 图片选择器回显
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selectedGoods.value.img = val.url;
if (pictureField.value === "bgImage") {
if (!selectedGoods.value.style) {
selectedGoods.value.style = {};
}
selectedGoods.value.style.bgImage = val.url;
return;
}
selectedGoods.value[pictureField.value] = val.url;
}
// 点击选择图片
function handleClickFile(item, index) {
function handleClickFile(item, index, field = "img") {
pictureField.value = field;
selectedGoods.value = item;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
const oss = ossManageRef.value;
if (!oss) return;
oss.selectImage = true;
if (field === "videoUrl") {
oss.fileType = "video";
} else {
oss.fileType = "pic";
}
oss.changeFileType();
});
}
function removeNotice(index) {

View File

@@ -3,37 +3,42 @@
<!-- 拖拽栏 ,展示栏 -->
<div class="model-view">
<div class="model-view-menu">
<draggable
class="model-view-menu-item"
:list="modelData"
:item-key="getModelKey"
:clone="cloneModel"
:move="handleMove"
v-bind="{
group: { name: 'model', pull: 'clone', put: false, animation: 150 },
sort: false,
ghostClass: 'ghost',
}"
<div
v-for="grp in modelGroups"
:key="grp.key"
class="model-group"
>
<template #item="{ element: model }">
<li
v-if="!model.drawer && !model.drawerPromotions"
class="model-item"
>
<img src alt />
<span>{{ model.name }}</span>
</li>
</template>
</draggable>
<div class="model-group-title">{{ grp.title }}</div>
<draggable
class="model-view-menu-item"
:list="grp.list"
:item-key="getModelKey"
:clone="cloneModel"
:move="handleMove"
:group="{ name: 'model', pull: 'clone', put: false, animation: 150 }"
:sort="false"
ghost-class="ghost"
>
<template #item="{ element: model }">
<li class="model-item">
<el-icon v-if="model.icon && iconMap[model.icon]" :size="22">
<component :is="iconMap[model.icon]" />
</el-icon>
<span>{{ model.name }}</span>
</li>
</template>
</draggable>
</div>
</div>
<div class="model-view-content">
<div class="content">
<div class="wap-title">首页</div>
<div class="page-setting-entry" @click="showPageSetting">页面设置</div>
<div class="content" :style="canvasStyle">
<div class="wap-title" :style="titleBarStyle">{{ contentData.title || "店铺首页" }}</div>
<draggable
class="draggable"
group="model"
ghostClass="ghost"
ghost-class="ghost"
@add="handleContentlAdd"
@end="handleContentlEnd"
v-model="contentData.list"
@@ -41,22 +46,40 @@
>
<template #item="{ element, index }">
<div class="list">
<component
<div class="floor-name">{{ element.name || element.type }}</div>
<div
v-if="templates[element.type]"
class="component"
:class="{ active: selected == index }"
@click="handleComponent(element, index)"
:is="templates[element.type]"
:res="element.options"
></component>
<el-icon
v-if="selected == index"
@click="closeComponent(index)"
class="close"
:size="25"
color="#e1251b"
>
<CircleClose />
</el-icon>
<component :is="templates[element.type]" :res="element.options"></component>
</div>
<div
v-else
class="component unknown-component"
@click="handleUnknownComponent(element, index)"
>
未知组件「{{ element.type }}」已下线,点击删除
</div>
<div v-if="element.hidden" class="floor-hidden-mask">已隐藏</div>
<div v-if="selected == index" class="floor-tools">
<el-icon
:class="{ disabled: index === 0 }"
@click="moveComponent(index, -1)"
><Top
/></el-icon>
<el-icon
:class="{ disabled: index === contentData.list.length - 1 }"
@click="moveComponent(index, 1)"
><Bottom
/></el-icon>
<el-icon @click="copyComponent(index)"><CopyDocument /></el-icon>
<el-icon @click="closeComponent(index)"><Delete /></el-icon>
<el-icon @click="toggleHide(index)"
><View v-if="!element.hidden" /><Hide v-else
/></el-icon>
</div>
</div>
</template>
</draggable>
@@ -70,30 +93,89 @@
v-if="decorateData"
:res="decorateData"
></decorate>
<PanelPageSetting v-else :res="contentData" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ref, watch, nextTick, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
Picture,
PictureFilled,
Document,
Grid,
Search,
Goods,
Bell,
VideoCamera,
Ticket,
Collection,
Crop,
Minus,
EditPen,
Top,
Bottom,
CopyDocument,
Delete,
View,
Hide,
} from "@element-plus/icons-vue";
import templates from "./template/index";
import Draggable from "vuedraggable";
import { modelData } from "./config";
import decorate from "./decorate";
import PanelPageSetting from "./config-panel/PanelPageSetting.vue";
import * as API_Other from "@/api/other";
import { getStore } from "@/libs/storage";
import { Modal } from "@/utils/message";
import { Message, Modal } from "@/utils/message";
import { useAppStore } from "@/stores/app";
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
// templates: 模板类型,modelData: 装修模型,均为 import 常量,模板中直接使用
// script setup 下 :is 无法按字符串解析局部导入,用映射表渲染组件图标
const iconMap = {
Picture,
PictureFilled,
Document,
Grid,
Search,
Goods,
Bell,
VideoCamera,
Ticket,
Collection,
Crop,
Minus,
EditPen,
};
const selected = ref(0); // 已选下标
// 页面设置默认值(老数据没有这些字段,加载时合并)
const PAGE_SETTING_DEFAULTS = {
name: "",
title: "",
bgType: "color", // color | image
bgColor: "#F5F5F5",
bgImage: "",
bgFill: "stretch", // stretch 拉伸 | contain 缩放 | cover 填充
topColor: "#ffffff",
titleColor: "black", // black | white
};
function mergePageData(data) {
const source = data && typeof data === "object" ? data : {};
return {
...PAGE_SETTING_DEFAULTS,
...source,
list: Array.isArray(source.list) ? source.list : [],
};
}
const contentData = ref({
// 总数据
...PAGE_SETTING_DEFAULTS,
list: [],
});
const decorateData = ref(""); // 装修数据
@@ -103,9 +185,52 @@ watch(
(val) => {
appStore.styleStore = val;
},
{ deep: true }
{ deep: true, immediate: true }
);
// 画布背景:背景色或背景图(拉伸/缩放/填充)
const canvasStyle = computed(() => {
const d = contentData.value;
if (d.bgType === "image" && d.bgImage) {
const size =
{ stretch: "100% 100%", contain: "contain", cover: "cover" }[d.bgFill] ||
"100% 100%";
return {
backgroundImage: `url(${d.bgImage})`,
backgroundSize: size,
backgroundRepeat: "no-repeat",
backgroundColor: d.bgColor || "#F5F5F5",
};
}
return { backgroundColor: d.bgColor || "#F5F5F5" };
});
// 画布顶部标题栏:背景用顶部颜色,文字色随标题颜色
const titleBarStyle = computed(() => {
const d = contentData.value;
return {
background: d.topColor || "#ffffff",
color: d.titleColor === "black" ? "#000" : "#fff",
};
});
// 清空楼层选中,右侧回到页面设置面板
function showPageSetting() {
selected.value = -1;
decorateData.value = "";
}
// 组件面板三类分组(drawer 标记的是图片魔方的可选风格,不进左侧组件列表)
const modelGroups = computed(() => {
const groups = [
{ key: "base", title: "基础组件" },
{ key: "marketing", title: "商品营销" },
{ key: "tools", title: "工具组件" },
];
return groups.map((g) => ({
...g,
list: modelData.filter((m) => m.group === g.key && !m.drawer),
}));
});
onMounted(() => {
init();
});
@@ -118,7 +243,12 @@ function getContentItemKey(element) {
}
function cloneModel(model) {
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
const cloned = JSON.parse(JSON.stringify(model));
// 图片魔方拖入画布时默认使用「左一右二」风格
const source =
model.type === "tpl_ad_list"
? modelData.find((m) => m.type === "leftOneRightTwo") || model
: model;
const cloned = JSON.parse(JSON.stringify(source));
return {
...cloned,
key,
@@ -127,29 +257,72 @@ function cloneModel(model) {
}
// 初始化数据
function init() {
// 先读缓存,如果缓存有值则读缓存。
const cache = getStore('managerMobilePageCache')
if(cache){
Modal.confirm({
title: '提示',
content: '获取到本地有缓存数据,是否使用缓存数据?',
okText: '使用',
cancelText: '取消',
// 先读缓存,如果缓存有值则询问是否使用缓存
const cache = getStore("sellerMobilePageCache") || getStore("managerMobilePageCache");
if (cache) {
Modal.confirm({
title: "提示",
content: "获取到本地有缓存数据,是否使用缓存数据?",
okText: "使用",
cancelText: "取消",
onOk: () => {
const data = JSON.parse(cache);
contentData.value = data;
}
contentData.value = mergePageData(data);
},
});
}
if (!route.query.id) {
if (route.query.mode !== "add" && (!route.query.pagetype || route.query.pagetype === "INDEX" || route.query.pagetype === "STORE")) {
API_Other.getHomeList({
pageNumber: 1,
pageSize: 10,
pageClientType: "H5",
}).then((res) => {
const records = (res && res.result && res.result.records) || [];
const active = records.find((r) => r.pageShow === "OPEN" || r.pageShow === true) || records[0];
if (!active || !active.id) return;
router.replace({
path: route.path,
query: {
...route.query,
id: active.id,
name: active.name,
type: active.pageShow === "OPEN" || active.pageShow === true,
},
});
API_Other.getHomeData(active.id).then((detail) => {
const parsed = JSON.parse(detail.result.pageData);
contentData.value = mergePageData(parsed);
if (!contentData.value.name) {
contentData.value.name = active.name || "";
}
const first = contentData.value.list[0];
if (first && templates[first.type]) {
handleComponent(first, 0);
}
});
});
return false;
}
if (!route.query.id) return false;
// 新建页面:模板名称取路由参数
if (!contentData.value.name) {
contentData.value.name = route.query.name || "店铺首页模板";
}
return false;
}
API_Other.getHomeData(route.query.id).then((res) => {
contentData.value = JSON.parse(res.result.pageData);
handleComponent(contentData.value.list[0], 0);
const parsed = JSON.parse(res.result.pageData);
contentData.value = mergePageData(parsed);
if (!contentData.value.name) {
contentData.value.name = route.query.name || res.result.name || "";
}
const first = contentData.value.list[0];
if (first && templates[first.type]) {
handleComponent(first, 0);
}
});
}
// 中间组件拖动,右侧数据绑定不变
@@ -165,17 +338,15 @@ function closeComponent(index) {
// 如果当前楼层不为一
if (contentData.value.list.length > 1) {
// 如果当前最底层 给下一层赋值
if (index - 1 == -1) {
handleComponent(contentData.value.list[index], index);
} else {
// 如果不是最底层给上一层赋值
handleComponent(contentData.value.list[index - 1], index - 1);
}
contentData.value.list.splice(index, 1);
} else {
contentData.value.list.splice(index, 1);
selected.value = -1;
}
});
}
@@ -185,9 +356,37 @@ function handleComponent(val, index) {
selected.value = index;
decorateData.value = val;
}
// 右侧栏回调
// 已下线的未知组件:点击占位条直接删除
function handleUnknownComponent(element, index) {
console.warn(`[page-decoration] 未知组件类型: ${element.type}`);
closeComponent(index);
}
// 上移/下移楼层
function moveComponent(index, dir) {
const target = index + dir;
if (target < 0 || target >= contentData.value.list.length) return;
const list = contentData.value.list;
[list[index], list[target]] = [list[target], list[index]];
selected.value = target;
handleComponent(contentData.value.list[target], target);
}
// 复制楼层
function copyComponent(index) {
const cloned = JSON.parse(JSON.stringify(contentData.value.list[index]));
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
cloned.key = key;
cloned.model = cloned.type + "_" + key;
contentData.value.list.splice(index + 1, 0, cloned);
handleComponent(contentData.value.list[index + 1], index + 1);
}
// 隐藏/显示楼层(渲染端跳过 hidden 楼层)
function toggleHide(index) {
const item = contentData.value.list[index];
item.hidden = !item.hidden;
}
// 右侧栏回调(广告魔方风格切换等)
function handleDrawer(val) {
const newIndex = selected.value;
let newIndex = selected.value;
decorateData.value = "";
contentData.value.list[newIndex] = {
...val,
@@ -196,11 +395,13 @@ function handleDrawer(val) {
},
model: val.type,
};
contentData.value = JSON.parse(JSON.stringify(contentData.value));
contentData.value.list = JSON.parse(
JSON.stringify(contentData.value.list)
);
decorateData.value = contentData.value.list[newIndex];
}
// 封装拖拽参数(原名 package,ESM 严格模式下为保留字,改名 packageModel)
// 封装拖拽参数
function packageModel(val, newIndex) {
contentData.value.list[newIndex] = "";
val = JSON.parse(JSON.stringify(val));
@@ -217,7 +418,6 @@ function packageModel(val, newIndex) {
// 拖动
function handleContentlAdd(evt) {
const { newIndex } = evt;
packageModel(contentData.value.list[newIndex], newIndex);
handleComponent(contentData.value.list[newIndex], newIndex);
}
@@ -227,4 +427,35 @@ function handleMove() {
</script>
<style scoped lang="scss">
@import "./style.scss";
// 页面设置:画布容器支持悬浮入口、画布背景可透出到拖拽区
.model-view-content {
position: relative;
}
.page-setting-entry {
position: absolute;
top: 12px;
right: 16px;
z-index: 200;
padding: 6px 14px;
font-size: 13px;
color: #409eff;
background: #ffffff;
border: 1px solid #dcdfe6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #409eff;
color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
}
.content > .draggable {
background: transparent;
}
.wap-title {
border-bottom: none;
}
</style>

View File

@@ -1,7 +1,26 @@
<template>
<!-- 预览保存 -->
<div class="model-title">
<div>店铺装修</div>
<div class="model-title-left">
<el-button size="default" @click="goBack">返回</el-button>
<div class="page-name">
<template v-if="!editingName">
<span class="page-name-label">当前页面:{{ pageName }}</span>
<el-icon class="page-name-edit" @click="startEditName"><Edit /></el-icon>
</template>
<el-input
v-else
ref="nameInput"
v-model="pageName"
size="small"
style="width: 200px"
maxlength="30"
placeholder="请输入模板名称"
@blur="editingName = false"
@keyup.enter="editingName = false"
/>
</div>
</div>
<div class="btns">
<el-button
@click="clickBtn(item)"
@@ -12,29 +31,22 @@
>{{ item.title }}</el-button>
</div>
<div class="model-title-view-btn">
<!-- TODO 后期会补全 目前版本暂无 -->
<!-- <Poptip placement="bottom" width="100">
<el-button size="default" @click="creatQrCode">预览模板</el-button>
<template #content><div class="default-view-content">
<div>临时预览</div>
<div ref="qrCodeUrl"></div>
</div>
</template></el-popconfirm>-->
<el-button size="default" type="default" v-if="hasCache" @click="clearCache">清空本地缓存</el-button>
<el-button size="default" type="primary" @click="handleSpinShow">保存模板</el-button>
<el-button size="default" type="primary" @click="handleSpinShow">保存页面</el-button>
<el-dialog
title="保存中"
:title="progress ? '保存页面' : '保存中'"
v-model="saveDialog"
:show-close="true"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@closed="handleDialogClosed"
>
<div v-if="progress">
<div class="model-item">
模板名称
<el-input style="width: 200px" v-model="submitWay.name" />
<el-input style="width: 200px" v-model="pageName" placeholder="请输入模板名称" />
</div>
<div class="model-item">
是否立即发布
@@ -46,7 +58,7 @@
</div>
<div class="model-item">
将当前装修内容写入到本地缓存中,下次进入页面时可继续使用
<el-button type="small" @click="witeLocalStore">写入</el-button>
<el-button size="small" @click="witeLocalStore">写入</el-button>
</div>
<el-button type="primary" @click="save()">保存</el-button>
@@ -57,12 +69,13 @@
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { reactive, ref, watch, onMounted, nextTick, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app";
import { Message } from "@/utils/message";
import { getStore, setStore } from "@/libs/storage";
import * as API_Other from "@/api/other.js";
import { Edit } from "@element-plus/icons-vue";
import { getStore, setStore } from "@/libs/storage";
import { Message } from "@/utils/message";
import { useAppStore } from "@/stores/app";
const props = defineProps(["pagetype"]);
const emit = defineEmits(["selected"]);
@@ -73,9 +86,10 @@ const appStore = useAppStore();
const hasCache = ref(false); // 是否有缓存
const progress = ref(true); // 展示进度
const num = ref(20); // 提交进度
const num = ref(0); // 提交进度
const saveDialog = ref(false); // 加载状态
const way = ref([
// 装修tab栏切换
{
title: "首页",
name: "index",
@@ -85,11 +99,48 @@ const way = ref([
const submitWay = reactive({
// 表单信息
pageShow: route.query.type || false,
name: route.query.name || "模板名称",
pageClientType: "H5"
pageShow: route.query.type === "true" || route.query.type === true || route.query.type === "OPEN",
name: route.query.name || "店铺首页模板",
pageClientType: "H5",
});
// 模板名称(优先读写 store 中的 name,便于与页面设置面板联动)
const editingName = ref(false);
const nameInput = ref(null);
const pageName = computed({
get() {
const fromStore = appStore.styleStore && appStore.styleStore.name;
return fromStore || submitWay.name;
},
set(val) {
submitWay.name = val;
if (appStore.styleStore && typeof appStore.styleStore === "object") {
appStore.styleStore.name = val;
}
},
});
function startEditName() {
editingName.value = true;
nextTick(() => {
nameInput.value?.focus();
});
}
onMounted(() => {
const store = appStore.styleStore;
if (store && typeof store === "object" && !store.name && route.query.name) {
store.name = route.query.name;
}
hasCache.value = !!(getStore("sellerMobilePageCache") || getStore("managerMobilePageCache"));
});
watch(
() => route.query.type,
(val) => {
submitWay.pageShow = val === "true" || val === true || val === "OPEN";
}
);
watch(
() => props.pagetype,
(val) => {
@@ -103,109 +154,144 @@ watch(
{ immediate: true }
);
onMounted(() => {
hasCache.value = getStore('managerMobilePageCache') ? true : false;
});
function clearCache() {
setStore('managerMobilePageCache','');
Message.success('清空成功')
setStore("sellerMobilePageCache", "");
setStore("managerMobilePageCache", "");
hasCache.value = false;
Message.success("清空成功");
}
// 将楼层装修的内容写入到本地缓存中
function witeLocalStore() {
setStore('managerMobilePageCache', appStore.styleStore)
Message.success('写入成功')
setStore("sellerMobilePageCache", appStore.styleStore);
hasCache.value = true;
Message.success("写入成功");
}
function clickBtn(val) {
way.value.forEach((item, index) => {
way.value.forEach((item) => {
item.selected = false;
});
val.selected = true;
emit("selected", val.name);
}
/**
* 加载,并保存模板
*/
function handleSpinShow() {
progress.value = true;
num.value = 0;
saveDialog.value = true;
}
// 填写是否发布,模板名称之后保存
function handleDialogClosed() {
progress.value = true;
num.value = 0;
}
function save() {
if (
appStore.styleStore == void 0 &&
route.query.pagetype != 'SPECIAL'
) {
if (!pageName.value || !pageName.value.trim()) {
Message.error("请填写模板名称");
return false;
}
const title = appStore.styleStore && appStore.styleStore.title;
if (!title || !String(title).trim()) {
Message.error("请填写页面标题");
return false;
}
if (appStore.styleStore == void 0 && route.query.pagetype != "SPECIAL") {
Message.error("请装修楼层");
return false;
}
submitWay?.pageShow === true
? (submitWay.pageShow = "OPEN")
: (submitWay.pageShow = "CLOSE");
const payload = {
...submitWay,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
name: pageName.value,
pageData: JSON.stringify(appStore.styleStore),
pageType: route.query.pagetype || "INDEX",
};
submitWay.pageData = JSON.stringify(appStore.styleStore);
submitWay.pageType = route.query.pagetype || "INDEX";
route.query.id ? updateHome() : submit(submitWay);
route.query.id ? updateHome(payload) : submit(payload);
}
// 更新
function updateHome() {
function updateHome(payload) {
progress.value = false;
API_Other.updateHome(route.query.id, {
num.value = 20;
const data = payload || {
pageData: JSON.stringify(appStore.styleStore),
name: submitWay.name,
pageShow: submitWay.pageShow,
pageType: submitWay.pageType,
name: pageName.value,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
pageType: submitWay.pageType || route.query.pagetype || "INDEX",
pageClientType: "H5",
})
.then(res => {
num.value = 50;
};
API_Other.updateHome(route.query.id, data)
.then((res) => {
num.value = 60;
if (res.success) {
num.value = 80;
/**制作保存成功动画¸ */
num.value = 100;
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("修改成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
}
})
.catch(error => {});
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
});
}
// 返回查询数据页面
function goBack() {
router.go(-1);
if (window.history.state?.back) {
router.go(-1);
} else {
router.push("/wapList");
}
}
// 保存
function submit(submitWay) {
function submit(payload) {
progress.value = false;
API_Other.setHomeSetup(submitWay)
.then(res => {
num.value = 50;
num.value = 20;
const data = payload || {
...submitWay,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
name: pageName.value,
pageData: JSON.stringify(appStore.styleStore),
pageType: route.query.pagetype || "INDEX",
};
API_Other.setHomeSetup(data)
.then((res) => {
num.value = 60;
if (res.success) {
num.value = 80;
/**制作保存成功动画¸ */
num.value = 100;
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("保存成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("保存失败,请稍后重试");
}
})
.catch(error => {});
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
});
}
</script>
<style scoped lang="scss">
@@ -219,17 +305,44 @@ function submit(submitWay) {
}
}
.model-title {
height: 70px;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
color: #333;
font-size: 14px;
> .model-title-view-btn {
> * {
margin: 0 10px;
background: #fff;
border-bottom: 1px solid #ebeef5;
padding: 0 16px;
margin-bottom: 12px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
.model-title-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #303133;
font-weight: 500;
}
.page-name-edit {
cursor: pointer;
color: #909399;
font-size: 14px;
&:hover {
color: #409eff;
}
}
> .model-title-view-btn {
display: flex;
align-items: center;
gap: 10px;
}
}
.btns {
* {

View File

@@ -1,12 +1,42 @@
.model-view {
display: flex;
gap: 12px;
height: calc(100vh - 96px);
.model-view-menu {
flex: 1.5;
min-width: 250px;
> .model-view-menu-item {
overflow-y: auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
padding: 14px 12px;
box-sizing: border-box;
.model-view-menu-item {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.model-group {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.model-group-title {
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 15px;
font-weight: 400;
color: #303133;
margin: 0 0 12px;
&::before {
content: "";
width: 3px;
height: 12px;
border-radius: 2px;
background: #409eff;
margin-right: 6px;
}
}
}
.model-config {
@@ -16,17 +46,44 @@
flex: 6;
}
.model-item {
line-height: 1.75;
font-size: 13px;
margin: 0 5px 10px 5px;
border: 1px solid #ededed;
background: #f6f6f9;
width: 70px;
height: 70px;
line-height: 1.4;
font-size: 12px;
border: 1px solid #ebeef5;
background: #ffffff;
border-radius: 8px;
height: 78px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 8px;
color: #606266;
cursor: grab;
transition: all 0.2s ease;
.el-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f0f5ff;
color: #409eff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
&:hover {
border-color: #c6e2ff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
transform: translateY(-2px);
color: #303133;
.el-icon {
background: #409eff;
color: #ffffff;
}
}
&:active {
cursor: grabbing;
}
}
}
.model-view-content {
@@ -36,6 +93,8 @@
justify-content: flex-start;
align-items: center;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.wap-title {
line-height: 44px;
@@ -48,12 +107,12 @@
.content {
box-sizing: border-box;
margin: 20px 0;
padding: 50px 13px;
width: 360px;
background: url("../../../assets/iPhoneX_model.png") no-repeat;
height: 780px;
background-size: 100% 100%;
overflow: hidden;
width: 375px;
background: #ebebeb;
min-height: calc(100vh - 200px);
border-radius: 8px;
overflow: visible;
flex-shrink: 0;
display: flex;
flex-direction: column;
@@ -61,8 +120,7 @@
flex: 1;
width: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overflow: visible;
background: #ebebeb;
padding-bottom: 100px;
box-sizing: border-box;
@@ -77,11 +135,67 @@
.list {
position: relative;
}
.close {
.floor-name {
position: absolute;
right: 5px;
top: 10px;
z-index: 99;
right: calc(100% + 8px);
top: 8px;
z-index: 60;
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 2px 8px;
font-size: 14px;
color: #606266;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
pointer-events: none;
}
.floor-tools {
position: absolute;
right: -56px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px 0;
.el-icon {
font-size: 20px;
padding: 9px 12px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
.floor-hidden-mask {
position: absolute;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.unknown-component {
padding: 16px 10px;
background: #fff2f0;
border: 1px dashed #ffccc7;
color: #cf1322;
font-size: 12px;
text-align: center;
cursor: pointer;
}
.model-btn {
@@ -97,15 +211,16 @@
text-align: center;
font-weight: bold;
line-height: 2;
:deep(img) {
:deep(img){
margin: 0 auto;
}
}
.model-config {
overflow-y: auto;
background: #f5f7fa;
}
.active {
box-sizing: border-box;
border: 1px solid $theme_color;
border: 2px solid $theme_color;
}

View File

@@ -0,0 +1,28 @@
// 图片魔方各布局预览共用的样式字段(面板配置;老数据无字段时返回空对象,保持原样)
// 画布 375px ≈ 750rpx,预览按数值一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
export function cubeWrapperStyle(res) {
if (!res || res.paddingTop === undefined) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
}
export function cubeImgStyle(res) {
if (!res || res.imgRadius === undefined) return {};
return { borderRadius: px(res.imgRadius), overflow: "hidden" };
}
export function cubeGapStyle(res) {
if (!res || res.contentGap === undefined) return {};
return { gap: px(res.contentGap) };
}

View File

@@ -12,20 +12,34 @@ import tpl_flex_four from "./tpl_flex_four";
import tpl_text_picture from "./tpl_text_picture";
import tpl_menu from "./tpl_menu";
import tpl_search from "./tpl_search";
import tpl_join_group from "./tpl_join_group";
import tpl_promotion_detail from "./tpl_promotion_detail";
import tpl_integral from "./tpl_integral";
import tpl_spike from "./tpl_spike";
import tpl_group from "./tpl_group";
import tpl_ad_list from "./tpl_view_list";
import tpl_activity_list from './tpl_view_list'
import tpl_activity_list from './tpl_view_list';
// import tpl_hot_area from './tpl_hot_area';
import tpl_video from "./tpl_video";
import tpl_goods from "./tpl_goods";
import tpl_notice from "./tpl_notice";
import tpl_promotions from "./tpl_promotions";
import tpl_blank from "./tpl_blank";
import tpl_line from "./tpl_line";
import tpl_rich_text from "./tpl_rich_text";
import tpl_coupon from "./tpl_coupon";
import tpl_article from "./tpl_article";
import tpl_shop from "./tpl_shop";
import tpl_home_comb from "./tpl_home_comb";
export default {
homeComb: tpl_home_comb,
notice: tpl_notice,
carousel: tpl_banner,
title: tpl_title,
leftOneRightTwo: tpl_left_one_right_two,
leftTwoRightOne: tpl_left_two_right_one,
topOneBottomTwo:tpl_top_one_bottom_two,
topTwoBottomOne:tpl_top_two_bottom_one,
topOneBottomTwo: tpl_top_one_bottom_two,
topTwoBottomOne: tpl_top_two_bottom_one,
flexThree: tpl_flex_three,
flexFive: tpl_flex_five,
flexFour: tpl_flex_four,
@@ -33,12 +47,21 @@ export default {
textPicture: tpl_text_picture,
menu: tpl_menu,
search: tpl_search,
joinGroup: tpl_join_group,
promotionDetail: tpl_promotion_detail,
flexOne: tpl_flex_one,
goods: tpl_goods,
integral:tpl_integral,
spike:tpl_spike,
group:tpl_group,
integral: tpl_integral,
spike: tpl_spike,
group: tpl_group,
// hotArea: tpl_hot_area,
video: tpl_video,
tpl_ad_list,
tpl_activity_list
promotions: tpl_promotions,
tpl_activity_list,
blank: tpl_blank,
line: tpl_line,
richText: tpl_rich_text,
coupon: tpl_coupon,
article: tpl_article,
shop: tpl_shop,
};

View File

@@ -6,7 +6,7 @@
}
.layout {
padding: 8px;
margin: 4px 0;
margin: 0;
background: #fff;
}
.layout,

View File

@@ -0,0 +1,154 @@
<template>
<div class="tpl-article" :class="{ styled: styled }" :style="wrapperStyle">
<div class="article-title" v-if="title">{{ title }}</div>
<template v-if="articleList.length">
<div class="article-item" v-for="item in articleList" :key="item.id">
<div class="article-text">
<div class="article-name" :style="nameStyle">{{ item.title }}</div>
<div class="article-time" v-if="item.createTime">{{ item.createTime }}</div>
</div>
</div>
</template>
<template v-else>
<div class="article-item" v-for="i in 3" :key="i">
<div class="article-text">
<div class="article-name" :style="nameStyle">文章标题示例文章标题示例</div>
</div>
<div class="article-thumb"></div>
</div>
</template>
</div>
</template>
<script>
import { getArticle } from "@/api/pages";
export default {
name: "tpl_article",
props: {
res: { type: Object, default: () => ({}) },
},
data() {
return { articleList: [] };
},
computed: {
first() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return this.first.title || "";
},
count() {
return this.first.count || 3;
},
categoryId() {
return this.first.categoryId || "";
},
// 面板保存过样式字段才启用自定义样式,老数据保持原样
styled() {
return this.res && this.res.paddingTop !== undefined;
},
nameStyle() {
return this.styled && this.res.titleColor
? { color: this.res.titleColor }
: {};
},
wrapperStyle() {
if (!this.styled) {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const res = this.res;
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
display: "flex",
flexDirection: "column",
rowGap: px(res.contentGap),
};
},
},
watch: {
categoryId() {
this.loadArticles();
},
count() {
this.loadArticles();
},
},
mounted() {
this.loadArticles();
},
methods: {
loadArticles() {
const params = { pageNumber: 1, pageSize: this.count };
if (this.categoryId) params.categoryId = this.categoryId;
getArticle(params)
.then((res) => {
const records = res && res.result && res.result.records;
this.articleList = Array.isArray(records)
? records.slice(0, this.count)
: [];
})
.catch(() => {
this.articleList = [];
});
},
},
};
</script>
<style scoped lang="scss">
.article-title {
font-size: 15px;
font-weight: 600;
padding: 4px 12px 10px;
}
.article-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid #f5f5f5;
}
.tpl-article.styled .article-item {
border-top: none;
padding: 0;
}
.article-text {
flex: 1;
min-width: 0;
}
.article-name {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.article-thumb {
width: 56px;
height: 42px;
background: #f0f0f0;
border-radius: 4px;
margin-left: 10px;
flex-shrink: 0;
}
</style>

View File

@@ -1,17 +1,151 @@
<template>
<div class="layout">
<el-carousel class="carousel" v-if="res" autoplay :interval="5000" height="150px">
<el-carousel-item v-for="(item, index) in res.list" :key="index">
<div>
<img alt="轮播图" class="image-mode" :src="item.img" />
<div class="layout" :style="wrapperStyle">
<div class="banner-box" :style="cardMode ? {} : boxStyle">
<el-carousel
class="carousel"
v-if="res && !cardMode"
autoplay
:interval="5000"
height="150px"
:indicator-position="customIndicator ? 'none' : ''"
@change="onChange"
>
<el-carousel-item v-for="(item, index) in res.list" :key="index">
<div class="slide-wrap">
<img alt="轮播图" class="image-mode" :style="boxStyle" :src="item.img" />
</div>
</el-carousel-item>
</el-carousel>
<!-- 卡片风格:当前图 82% 居中放大,两侧 9% 露出并缩小,循环切换 -->
<div
v-if="res && cardMode"
ref="cardPreview"
class="card-preview"
@mouseenter="stopAuto"
@mouseleave="startAuto"
>
<div
class="card-track"
:style="{ transform: `translateX(${trackOffset}px)` }"
>
<div
class="card-slide"
:class="{ active: index === current }"
v-for="(item, index) in res.list"
:key="index"
>
<img alt="轮播图" class="card-img" :style="boxStyle" :src="item.img" />
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<div
v-if="customIndicator && res.indicatorStyle !== 'none' && res.list.length > 1"
class="indicator"
:class="'pos-' + (res.indicatorPosition || 'center')"
>
<div
v-for="(item, i) in res.list"
:key="i"
class="dot"
:class="[res.indicatorStyle || 'round', { active: i === current }]"
:style="i === current ? { background: indicatorColor } : {}"
></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from "vue";
defineOptions({ title: "导航栏" });
defineProps(["res"]);
const props = defineProps(["res"]);
const current = ref(0);
const previewWidth = ref(0);
const cardPreview = ref(null);
let timer = null;
// 配置过样式字段才启用自定义样式/指示器,老数据保持原样
const customIndicator = computed(
() => props.res && props.res.indicatorStyle !== undefined
);
const cardMode = computed(() => props.res && props.res.swiperStyle === "card");
const indicatorColor = computed(() =>
props.res.indicatorColorMode === "custom" && props.res.indicatorColor
? props.res.indicatorColor
: "#409eff"
);
// 卡片宽 82%,两侧各露出 9%
const trackOffset = computed(
() => previewWidth.value * 0.09 - current.value * previewWidth.value * 0.82
);
const wrapperStyle = computed(() => {
const res = props.res || {};
if (res.paddingTop === undefined) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
// 画布 375px ≈ 750rpx,预览按一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
};
});
const boxStyle = computed(() => {
const res = props.res || {};
if (res.imgRadius === undefined) return {};
return { borderRadius: `${(Number(res.imgRadius) || 0) / 2}px` };
});
function onChange(index) {
current.value = index;
}
function measure() {
if (cardPreview.value) {
previewWidth.value = cardPreview.value.offsetWidth;
}
}
function startAuto() {
stopAuto();
if (!cardMode.value || !props.res.list || props.res.list.length < 2) return;
timer = setInterval(() => {
current.value = (current.value + 1) % props.res.list.length;
}, 5000);
}
function stopAuto() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
watch(
cardMode,
(val) => {
stopAuto();
if (val) {
measure();
startAuto();
}
},
{ immediate: true, flush: "post" }
);
watch(
() => props.res.list && props.res.list.length,
(len) => {
if (current.value >= len) current.value = 0;
}
);
onBeforeUnmount(stopAuto);
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -19,4 +153,79 @@ defineProps(["res"]);
.image-mode {
height: 150px;
}
</style>
.banner-box {
position: relative;
overflow: hidden;
}
.slide-wrap {
height: 100%;
padding: 0 3px;
box-sizing: border-box;
}
.image-mode {
width: 100%;
display: block;
overflow: hidden;
}
.card-preview {
overflow: hidden;
height: 150px;
display: flex;
align-items: center;
}
.card-track {
display: flex;
align-items: center;
transition: transform 0.5s ease;
width: 100%;
}
.card-slide {
flex: 0 0 82%;
padding: 0 3px;
box-sizing: border-box;
transform: scale(0.9);
transition: transform 0.4s ease;
&.active {
transform: scale(1);
}
}
.card-img {
width: 100%;
height: 150px;
display: block;
overflow: hidden;
}
.indicator {
position: absolute;
bottom: 8px;
display: flex;
align-items: center;
z-index: 2;
&.pos-left {
left: 12px;
}
&.pos-center {
left: 50%;
transform: translateX(-50%);
}
&.pos-right {
right: 12px;
}
.dot {
background: rgba(255, 255, 255, 0.7);
transition: background 0.3s ease;
&.round {
width: 6px;
height: 6px;
border-radius: 50%;
margin: 0 3px;
}
&.line {
width: 12px;
height: 3px;
border-radius: 2px;
margin: 0 2px;
}
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div
class="tpl-blank"
:style="{
background: blankBg,
height: px(res.height ?? 10),
margin: `0 ${px(res.paddingX ?? 0)} ${px(res.pageGap ?? 0)}`,
}"
></div>
</template>
<script>
const px = (v) => `${(Number(v) || 0) / 2}px`;
export default {
name: "tpl_blank",
props: {
res: { type: Object, default: () => ({}) },
},
methods: {
px,
},
computed: {
blankBg() {
const res = this.res || {};
if (res.bgColor1 !== undefined) {
return !res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
}
return res.bgColor || "#f5f5f5";
},
},
};
</script>

View File

@@ -0,0 +1,441 @@
<template>
<div class="tpl-coupon" :class="{ styled: styled }" :style="wrapperStyle">
<div class="coupon-title" v-if="title">{{ title }}</div>
<!-- 老数据保持原样 -->
<div v-if="!styled" class="coupon-list">
<div class="coupon-item" v-for="i in 2" :key="i">
<div class="coupon-left">
<div class="coupon-amount">
<span class="coupon-symbol">¥</span>10
</div>
<div class="coupon-threshold">满100可用</div>
</div>
<div class="coupon-right">
<div class="coupon-name">优惠券名称</div>
<div class="coupon-btn">立即领取</div>
</div>
</div>
</div>
<!-- 样式一:红色大卡横滑,右侧竖排领取 -->
<div v-else-if="layoutStyle === '1'" class="cp-scroll" :style="scrollGap">
<div class="cp1-item" v-for="(item, i) in displayData" :key="i">
<div class="cp1-left">
<div class="cp1-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp1-value">{{ item.couponDiscount }}</span
><span class="cp1-symbol">折</span>
</template>
<template v-else>
<span class="cp1-symbol">¥</span
><span class="cp1-value">{{ item.price }}</span>
</template>
</div>
<div class="cp1-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp1-right">
<span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
<!-- 样式二:白底小卡,底部去领取按钮 -->
<div v-else-if="layoutStyle === '2'" class="cp-scroll" :style="scrollGap">
<div class="cp2-item" v-for="(item, i) in displayData" :key="i">
<div class="cp2-top">
<div class="cp2-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp2-value">{{ item.couponDiscount }}</span
><span class="cp2-symbol">折</span>
</template>
<template v-else>
<span class="cp2-symbol">¥</span
><span class="cp2-value">{{ item.price }}</span>
</template>
</div>
<div class="cp2-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp2-btn">去领取</div>
</div>
</div>
<!-- 样式三:浅粉虚线卡,右侧竖排立即领取 -->
<div v-else-if="layoutStyle === '3'" class="cp-scroll" :style="scrollGap">
<div class="cp3-item" v-for="(item, i) in displayData" :key="i">
<div class="cp3-left">
<div class="cp3-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp3-value">{{ item.couponDiscount }}</span
><span class="cp3-symbol">折</span>
</template>
<template v-else>
<span class="cp3-symbol">¥</span
><span class="cp3-value">{{ item.price }}</span>
</template>
</div>
<div class="cp3-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp3-right">
<span>立</span><span>即</span><span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
<!-- 样式四:浅粉渐变 + 右侧渐变竖条领取 -->
<div v-else class="cp-scroll" :style="scrollGap">
<div class="cp4-item" v-for="(item, i) in displayData" :key="i">
<div class="cp4-left">
<div class="cp4-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp4-value">{{ item.couponDiscount }}</span
><span class="cp4-symbol">折</span>
</template>
<template v-else>
<span class="cp4-symbol">¥</span
><span class="cp4-value">{{ item.price }}</span>
</template>
</div>
<div class="cp4-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp4-right">
<span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
</div>
</template>
<script>
export default {
name: "tpl_coupon",
props: {
res: { type: Object, default: () => ({}) },
},
computed: {
firstItem() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return this.firstItem.title || "";
},
layoutStyle() {
return (this.res && this.res.layoutStyle) || "1";
},
displayData() {
const data = this.firstItem.data || [];
return data.slice(0, this.firstItem.count || 5);
},
// 面板保存过样式字段才启用自定义样式,老数据保持原样
styled() {
return this.res && this.res.paddingTop !== undefined;
},
scrollGap() {
if (!this.styled) return {};
return { columnGap: `${(Number(this.res.contentGap) || 0) / 2}px` };
},
wrapperStyle() {
if (!this.styled) {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const res = this.res;
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: "hidden",
};
},
},
methods: {
thresholdText(item) {
const t = Number(item.consumeThreshold);
return t > 0 ? `满${item.consumeThreshold}元可用` : "无门槛使用";
},
},
};
</script>
<style scoped lang="scss">
.tpl-coupon {
font-size: 13px;
}
.coupon-title {
font-size: 15px;
font-weight: 600;
padding: 4px 10px 10px;
}
.coupon-item {
display: flex;
margin: 0 10px 10px;
border-radius: 6px;
overflow: hidden;
background: linear-gradient(90deg, #ffece8, #fff5f4);
}
.coupon-left {
width: 90px;
padding: 12px 0;
text-align: center;
color: #e1251b;
border-right: 1px dashed #f0c0bb;
}
.coupon-amount {
font-size: 20px;
font-weight: 700;
}
.coupon-symbol {
font-size: 12px;
}
.coupon-threshold {
font-size: 11px;
}
.coupon-right {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
}
.coupon-name {
color: #333;
}
.coupon-btn {
background: #e1251b;
color: #fff;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
}
.tpl-coupon.styled .coupon-item {
margin: 0;
}
.tpl-coupon.styled .coupon-list {
display: contents;
}
/* 参考 CRMEB 优惠券组件 */
.tpl-coupon.styled .coupon-title {
padding: 0 0 8px;
}
.cp-scroll {
display: flex;
column-gap: 4px;
overflow: hidden;
}
.cp-notch {
position: absolute;
right: 30px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
}
.cp-notch-t {
top: -4px;
}
.cp-notch-b {
bottom: -4px;
}
/* 样式一:红色大卡 */
.cp1-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 132px;
height: 66px;
border-radius: 6px;
background: linear-gradient(135deg, #ff5a36, #e1251b);
color: #fff;
}
.cp1-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 12px;
}
.cp1-value {
font-size: 20px;
font-weight: 700;
}
.cp1-symbol {
font-size: 11px;
}
.cp1-threshold {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
margin-top: 2px;
}
.cp1-right {
width: 30px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
border-left: 1px dashed rgba(255, 255, 255, 0.6);
}
.cp1-item .cp-notch {
right: 26px;
}
/* 样式二:白底小卡 */
.cp2-item {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 74px;
border-radius: 6px;
background: #fff;
border-left: 2px solid #f5483a;
border-right: 2px solid #f5483a;
overflow: hidden;
}
.cp2-top {
padding: 8px 0 6px;
text-align: center;
}
.cp2-value {
font-size: 17px;
font-weight: 700;
color: #e1251b;
}
.cp2-symbol {
font-size: 10px;
color: #e1251b;
}
.cp2-threshold {
font-size: 9px;
color: #999;
margin-top: 1px;
}
.cp2-btn {
background: linear-gradient(90deg, #ff6a3d, #e1251b);
color: #fff;
font-size: 10px;
text-align: center;
padding: 4px 0;
}
/* 样式三:浅粉虚线卡 */
.cp3-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 138px;
height: 60px;
border-radius: 6px;
background: #fdf1ec;
border: 1px dashed #f5483a;
}
.cp3-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 12px;
}
.cp3-value {
font-size: 19px;
font-weight: 700;
color: #e1251b;
}
.cp3-symbol {
font-size: 10px;
color: #e1251b;
}
.cp3-threshold {
font-size: 10px;
color: #e1251b;
margin-top: 1px;
}
.cp3-right {
width: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #e1251b;
border-left: 1px dashed #f5483a;
}
.cp3-item .cp-notch {
right: 28px;
}
/* 样式四:浅粉渐变 + 渐变竖条 */
.cp4-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 116px;
height: 54px;
border-radius: 6px;
overflow: hidden;
}
.cp4-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 10px;
background: linear-gradient(135deg, #fff3ec, #ffe0d3);
}
.cp4-value {
font-size: 17px;
font-weight: 700;
color: #e1251b;
}
.cp4-symbol {
font-size: 10px;
color: #e1251b;
}
.cp4-threshold {
font-size: 9px;
color: #e1251b;
margin-top: 1px;
}
.cp4-right {
width: 26px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
background: linear-gradient(180deg, #ff6a3d, #e1251b);
}
.cp4-item .cp-notch {
right: 22px;
width: 6px;
height: 6px;
}
.cp4-item .cp-notch-t {
top: -3px;
}
.cp4-item .cp-notch-b {
bottom: -3px;
}
</style>

View File

@@ -1,17 +1,24 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<img class="image-mode" :src="res.list[3].img" alt="">
<img class="image-mode" :src="res.list[4].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[3].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[4].img" alt="">
</div>
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "五列单行图片模块" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -23,6 +30,7 @@ defineProps(["res"]);
background-size: cover;
}
img {
width: 67px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,19 +1,23 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<img class="image-mode" :src="res.list[3].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[3].img" alt="">
</div>
</template>
<script setup>
defineOptions({
title: "四列单行图片模块",
});
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineProps(["res"]);
defineOptions({ title: "四列单行图片模块" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -28,6 +32,7 @@ defineProps(["res"]);
}
img{
width: 84px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,12 +1,32 @@
<template>
<div class="layout">
<div class="flex-one">
<div
class="layout"
:style="{
background: wrapperBg,
padding: `${px(res.paddingTop ?? 0)} ${px(res.paddingX ?? 0)} ${px(res.paddingBottom ?? 0)}`,
marginBottom: px(res.pageGap ?? 0),
borderRadius: px(res.borderRadius ?? 0),
overflow: (res.borderRadius ?? 0) > 0 ? 'hidden' : 'visible',
}"
>
<div class="flex-one" :style="{ borderRadius: px(res.imgRadius ?? 0) }">
<img :src="res.list[0].img" alt="">
</div>
</div>
</template>
<script setup>
defineProps(['res']);
import { computed } from 'vue'
const props = defineProps(['res'])
const px = (v) => `${(Number(v) || 0) / 2}px`
const wrapperBg = computed(() => {
const r = props.res || {}
if (r.bgColor1 !== undefined) {
return !r.bgColor2 || r.bgColor1 === r.bgColor2
? r.bgColor1
: `linear-gradient(180deg, ${r.bgColor1}, ${r.bgColor2})`
}
return r.bgColor ?? 'transparent'
})
</script>
<style lang="scss" scoped>
@import './tpl.scss';
@@ -19,4 +39,4 @@ defineProps(['res']);
height: 100%;
}
}
</style>
</style>

View File

@@ -1,18 +1,22 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
</div>
</template>
<script setup>
defineOptions({
title: "三列单行图片模块",
});
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineProps(["res"]);
defineOptions({ title: "三列单行图片模块" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -25,6 +29,7 @@ defineProps(["res"]);
background-size: cover;
}
img{
width: 111px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,18 +1,25 @@
<template>
<div class="layout">
<div class="flex-two">
<div class="flex-item">
<div class="layout" :style="wrapperStyle">
<div class="flex-two" :style="gapStyle">
<div class="flex-item" :style="imgStyle">
<img :src="res.list[0].img" alt />
</div>
<div class="flex-item">
<div class="flex-item" :style="imgStyle">
<img :src="res.list[1].img" alt />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "两张横图" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -23,10 +30,11 @@ defineProps(["res"]);
overflow: hidden;
}
.flex-item {
width: 50%;
flex: 1;
min-width: 0;
> img {
display: block;
max-width: 100%;
width: 100%;
height: 100%;
}
}

View File

@@ -1,97 +1,484 @@
<template>
<div v-if="goodsBlock" class="layout">
<div class="goods-cell-title">
<div
class="goods-item-title"
:class="{ selected: selected.index == index }"
@click="handleClickTitle(title, index)"
v-for="(title, index) in goodsBlock.titleWay"
:key="index"
>
<h4>{{ title.title }}</h4>
<div>{{ title.desc }}</div>
</div>
</div>
<div class="goods-list">
<template v-for="(item, item_index) in goodsBlock.listWay" :key="item_index">
<div v-if="isGoodsVisible(item)" class="goods-item">
<div class="goods-img">
<el-icon
:size="20"
color="#e1251b"
@click="closeGoods(item, item_index)"
class="goods-icon"
>
<CircleClose />
</el-icon>
<img :src="item.img" alt />
<div class="tpl-goods" :class="{ 'legacy-mode': !isNew }" :style="wrapperStyle">
<!-- 新版商品列表 -->
<template v-if="isNew">
<div v-if="displayGoods.length" class="g-list" :class="'mode-' + layout" :style="listStyle">
<div class="g-item" v-for="(g, i) in displayGoods" :key="i" :style="itemStyle">
<div class="g-imgbox">
<img :src="g.img" alt="" />
</div>
<div class="goods-desc">
<div class="goods-title">
{{ item.title }}
<div class="g-info">
<div v-if="showName" class="g-name" :style="nameStyle">{{ g.name }}</div>
<div v-if="showPrice" class="g-price" :style="priceStyle">
¥{{ $filters.unitPrice(g.price) }}
</div>
<div class="goods-bottom">
<div class="goods-price">¥{{ $filters.unitPrice(item.price) }}</div>
<div class="g-bottom">
<span
v-if="showSales && g.sales != null"
class="g-sales"
:style="salesStyle"
>已售 {{ g.sales }} 件</span
>
<span
v-if="cartShow"
class="g-cart"
:class="'cart-' + cartStyle"
:style="cartStyleObj"
>
<template v-if="cartStyle === 'text'">加购</template>
<template v-else-if="cartStyle === 'plus'">+</template>
<svg v-else viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path
d="M7 4h-2l-1 2h2l2.4 9.6a2 2 0 0 0 1.9 1.4h7.9a2 2 0 0 0 1.9-1.5l1.6-6.5h-12l-.8-3h11.5l.5-2h-13.4zM10 20a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"
/>
</svg>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="g-empty">暂无商品</div>
</template>
<!-- 旧版:分类标签 + 绑定商品 -->
<template v-else-if="goodsBlock">
<div class="goods-cell-title">
<div
class="goods-item-title"
:class="{ selected: selected.index == index }"
@click="handleClickTitle(title, index)"
v-for="(title, index) in goodsBlock.titleWay"
:key="index"
>
<h4>{{ title.title }}</h4>
<div>{{ title.desc }}</div>
</div>
</div>
<div class="goods-list">
<template v-for="(item, item_index) in goodsBlock.listWay" :key="item_index">
<div v-if="isGoodsVisible(item)" class="goods-item">
<div class="goods-img">
<img :src="item.img" alt />
</div>
<div class="goods-desc">
<div class="goods-title">
{{ item.title }}
</div>
<div class="goods-bottom">
<div class="goods-price">¥{{ $filters.unitPrice(item.price) }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
<script setup>
import { computed, reactive, watch } from "vue";
import { CircleClose } from "@element-plus/icons-vue";
const props = defineProps(["res"]);
<script>
import { getGoodsListData } from "@/api/goods";
const selected = reactive({
index: 0,
val: "",
});
const goodsBlock = computed(() => props.res?.list?.[0] || null);
watch(
goodsBlock,
(val) => {
if (!val) {
return;
}
const firstGoods = (val.listWay || []).find(Boolean);
if (firstGoods) {
selected.val = firstGoods.type;
} else if (val.titleWay?.[0]?.title) {
selected.val = val.titleWay[0].title;
export default {
name: "tpl_goods",
props: {
res: { type: Object, default: () => ({}) },
},
data() {
return {
selected: { index: 0, val: "" },
goodsData: [],
};
},
computed: {
first() {
return (this.res.list && this.res.list[0]) || {};
},
// 新版标记:面板写入过 layout 字段
isNew() {
return this.first.layout !== undefined;
},
layout() {
return this.first.layout || "list";
},
showName() {
return this.first.showName !== false;
},
showPrice() {
return this.first.showPrice !== false;
},
showSales() {
return this.first.showSales !== false;
},
cartShow() {
return this.first.cartShow !== false;
},
cartStyle() {
return this.first.cartStyle || "plus";
},
themePrice() {
return "#FF5C58";
},
priceColor() {
return this.res.toneMode === "custom" && this.res.priceColor
? this.res.priceColor
: this.themePrice;
},
priceStyle() {
return { color: this.priceColor };
},
nameStyle() {
return this.res.titleColor ? { color: this.res.titleColor } : {};
},
salesStyle() {
return this.res.salesColor ? { color: this.res.salesColor } : {};
},
cartStyleObj() {
return { background: this.priceColor };
},
px() {
return (v) => `${(Number(v) || 0) / 2}px`;
},
wrapperStyle() {
const res = this.res;
if (!this.isNew) {
return {};
}
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
background: bg,
padding: `${this.px(res.paddingTop)} ${this.px(res.paddingX)} ${this.px(res.paddingBottom)}`,
marginBottom: this.px(res.pageGap),
borderRadius: this.px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
},
listStyle() {
const gap = this.px(this.res.contentGap ?? 10);
return { gap, "--g-gap": gap };
},
itemStyle() {
const r = this.px(this.res.contentRadius);
return { borderRadius: r, overflow: Number(this.res.contentRadius) > 0 ? "hidden" : "visible" };
},
displayGoods() {
if (this.first.dataMode === "goods") {
return (this.first.goodsList || []).map((g) => ({
name: g.goodsName,
price: g.price,
img: g.thumbnail,
sales: g.buyCount != null ? g.buyCount : null,
}));
}
return this.goodsData;
},
goodsBlock() {
return this.res && this.res.list && this.res.list[0] ? this.res.list[0] : null;
},
},
watch: {
"first.dataMode": "loadGoods",
"first.categoryId": "loadGoods",
"first.brandId": "loadGoods",
"first.storeId": "loadGoods",
"first.count": "loadGoods",
"first.sortType": "loadGoods",
},
created() {
if (this.isNew) {
this.loadGoods();
} else {
const block = this.goodsBlock;
if (block) {
const firstGoods = (block.listWay || []).find(Boolean);
if (firstGoods) {
this.selected.val = firstGoods.type;
} else if (block.titleWay && block.titleWay[0]) {
this.selected.val = block.titleWay[0].title;
}
}
}
},
{ immediate: true }
);
function isGoodsVisible(item) {
if (!item) {
return false;
}
if (item.___index != undefined) {
return selected.index == item.___index;
}
return selected.val == item.type;
}
function closeGoods(val, index) {
goodsBlock.value.listWay.splice(index, 1);
}
function handleClickTitle(val, index) {
selected.index = index;
selected.val = val.title;
}
methods: {
loadGoods() {
const item = this.first;
if (!this.isNew || item.dataMode === "goods") {
return;
}
const params = {
pageNumber: 1,
pageSize: item.count || 6,
goodsStatus: "UPPER",
};
if (item.dataMode === "category" && item.categoryId) {
// category_path 以逗号包裹,前后补逗号避免误匹配
params.categoryPath = `,${item.categoryId},`;
}
if (item.dataMode === "brand" && item.brandId) {
params.brandId = item.brandId;
}
if (item.dataMode === "store" && item.storeId) {
params.storeId = item.storeId;
}
if (item.sortType === "sales") {
params.sort = "buyCount";
params.order = "desc";
} else if (item.sortType === "price") {
params.sort = "price";
params.order = "asc";
}
getGoodsListData(params)
.then((res) => {
const records = (res && res.result && res.result.records) || [];
this.goodsData = records.map((g) => ({
name: g.goodsName,
price: g.price,
img: g.thumbnail || g.small || g.original,
sales: g.buyCount != null ? g.buyCount : null,
}));
})
.catch(() => {
this.goodsData = [];
});
},
isGoodsVisible(item) {
if (!item) {
return false;
}
if (item.___index != undefined) {
return this.selected.index == item.___index;
}
return this.selected.val == item.type;
},
handleClickTitle(val, index) {
this.selected.index = index;
this.selected.val = val.title;
},
},
};
</script>
<style lang="scss" scoped>
.layout {
padding: 8px 0;
background: #e8e8e8;
.tpl-goods {
background: #fff;
}
.legacy-mode {
background: #e8e8e8;
padding: 8px 0;
}
.g-empty {
padding: 30px 0;
text-align: center;
color: #999;
font-size: 12px;
}
.g-list {
display: flex;
flex-wrap: wrap;
}
.g-item {
background: #fff;
display: flex;
box-sizing: border-box;
}
.g-imgbox {
flex-shrink: 0;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
.g-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.g-name {
font-size: 13px;
color: #282828;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.g-bottom {
display: flex;
align-items: center;
margin-top: auto;
}
.g-price {
color: $theme_color;
font-size: 15px;
font-weight: 600;
margin-bottom: 2px;
}
.g-sales {
flex: 1;
min-width: 0;
font-size: 11px;
color: #666;
}
.g-cart {
flex-shrink: 0;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
&.cart-plus,
&.cart-cart {
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 15px;
line-height: 1;
}
&.cart-text {
border-radius: 12px;
padding: 3px 10px;
font-size: 12px;
}
}
/* 卡片:左图右文(参考 CRMEB 详细列表,左侧 112x112 缩略图,右侧标题置顶、价格销量置底) */
.mode-card {
flex-direction: column;
.g-item {
flex-direction: row;
align-items: stretch;
padding: 10px;
}
.g-imgbox {
width: 112px;
height: 112px;
margin-right: 12px;
border-radius: 6px;
overflow: hidden;
}
.g-info {
min-height: 112px;
justify-content: space-between;
}
.g-name {
font-size: 14px;
font-weight: 500;
line-height: 20px;
margin-bottom: 6px;
}
.g-price {
font-size: 16px;
font-weight: 700;
margin-top: auto;
margin-bottom: 4px;
}
.g-bottom {
margin-top: 0;
}
.g-sales {
font-size: 12px;
}
}
/* 列表:两列网格 */
.mode-list {
.g-item {
width: calc((100% - var(--g-gap, 5px)) / 2);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 8px 10px 10px;
min-height: 64px;
}
.g-name {
margin-bottom: 6px;
}
}
/* 三列 */
.mode-three {
.g-item {
width: calc((100% - var(--g-gap, 5px) * 2) / 3);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 6px;
min-height: 48px;
}
.g-name {
-webkit-line-clamp: 1;
font-size: 11px;
margin-bottom: 2px;
}
.g-price {
font-size: 12px;
}
.g-sales {
font-size: 10px;
}
.g-cart {
&.cart-plus,
&.cart-cart {
width: 20px;
height: 20px;
font-size: 13px;
}
}
}
/* 大图:一行一个 */
.mode-big {
flex-direction: column;
.g-item {
width: 100%;
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 3 / 2;
}
.g-info {
padding: 10px 12px 12px;
min-height: 64px;
}
.g-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.g-price {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
}
}
/* 旧版样式 */
.selected {
> h4 {
color: #000 !important;
@@ -105,7 +492,6 @@ function handleClickTitle(val, index) {
padding: 10px;
transition: 0.35s;
display: flex;
// cursor: pointer;
> .goods-item-title {
flex: 1;
text-align: center;
@@ -156,9 +542,7 @@ function handleClickTitle(val, index) {
display: -webkit-box;
font-weight: 500;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
> .goods-bottom {
@@ -169,9 +553,4 @@ function handleClickTitle(val, index) {
}
}
}
.goods-icon {
right: 5px;
top: 5px;
position: absolute;
}
</style>

View File

@@ -0,0 +1,564 @@
<template>
<div class="tpl-home-comb" :style="wrapperStyle">
<!-- 高斯模糊轮播图背景 + 底部渐变遮罩 -->
<div class="comb-bg">
<img v-if="currentBannerImg" :src="currentBannerImg" alt="" />
<div class="comb-mask"></div>
</div>
<!-- 1. 搜索栏(可选左侧 Logo + 搜索框 + 小程序胶囊) -->
<div class="comb-search-row">
<img
v-if="res.searType === 'logo' && res.logoUrl"
class="comb-logo"
:src="res.logoUrl"
alt="logo"
/>
<div class="comb-search-box" :style="searchBoxStyle">
<el-icon class="search-ico"><Search /></el-icon>
<span class="search-text">{{ displayHotWord }}</span>
</div>
<div class="capsule">
<span class="cap-icon">
<svg width="18" height="5" viewBox="0 0 18 5" fill="currentColor">
<circle cx="2.5" cy="2.5" r="2" />
<circle cx="9" cy="2.5" r="2" />
<circle cx="15.5" cy="2.5" r="2" />
</svg>
</span>
<span class="cap-divider"></span>
<span class="cap-icon">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="7.5" cy="7.5" r="6" stroke="currentColor" stroke-width="1.6" />
<circle cx="7.5" cy="7.5" r="2.2" fill="currentColor" />
</svg>
</span>
</div>
</div>
<!-- 2. 分类选项卡 -->
<div v-if="res.showTabs !== false" class="comb-tabs">
<div class="comb-tabs-scroll">
<div
v-for="(tab, idx) in displayTabs"
:key="idx"
class="comb-tab-item"
:class="{ active: activeTabIndex === idx }"
:style="{
color:
activeTabIndex === idx
? res.tabActiveColor || '#E93323'
: res.tabColor || '#FFFFFF',
}"
@click.stop="activeTabIndex = idx"
>
{{ tab.title || "未命名" }}
<span
v-if="activeTabIndex === idx"
class="tab-line"
:style="{ background: res.tabActiveColor || '#E93323' }"
></span>
</div>
</div>
<div class="comb-tab-more" :style="{ color: res.tabColor || '#FFFFFF' }">
<el-icon><ArrowDown /></el-icon>
</div>
</div>
<!-- 3. 轮播图区域(样式1:卡片露边 / 样式2:通栏) -->
<div class="comb-banner" :class="{ 'no-tabs': res.showTabs === false, 'is-card': cardMode }">
<!-- 样式2:通栏轮播 -->
<el-carousel
v-if="!cardMode && bannerList.length"
class="comb-carousel"
autoplay
:interval="5000"
height="146px"
indicator-position="none"
@change="onSlideChange"
>
<el-carousel-item v-for="(item, index) in bannerList" :key="index">
<div class="slide-wrap">
<img
v-if="item.img"
class="slide-img"
:style="bannerImgStyle"
:src="item.img"
alt=""
/>
<div v-else class="slide-empty" :style="bannerImgStyle">750*332</div>
</div>
</el-carousel-item>
</el-carousel>
<!-- 样式1:卡片风格(两侧露出前后图片) -->
<div
v-else-if="cardMode && bannerList.length"
ref="cardPreview"
class="card-preview"
@mouseenter="stopBannerTimer"
@mouseleave="startBannerTimer"
>
<div
class="card-track"
:style="{ transform: `translateX(${trackOffset}px)` }"
>
<div
v-for="(item, index) in cardSlides"
:key="index"
class="card-slide"
:class="{ active: index === activeTrackIndex }"
>
<img
v-if="item.img"
class="card-img"
:style="bannerImgStyle"
:src="item.img"
alt=""
/>
<div v-else class="slide-empty" :style="bannerImgStyle">750*332</div>
</div>
</div>
</div>
<div v-else class="slide-empty" :style="bannerImgStyle">请添加轮播图</div>
<!-- 自定义指示器 -->
<div
v-if="res.indicatorStyle !== 'none' && bannerList.length > 1"
class="comb-indicator"
:class="'pos-' + (res.indicatorPosition || 'center')"
>
<div
v-for="(item, i) in bannerList"
:key="i"
class="dot"
:class="[res.indicatorStyle || 'round', { active: i === currentSlide }]"
:style="
i === currentSlide
? { background: res.indicatorColor || '#E93323' }
: {}
"
></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { Search, ArrowDown } from "@element-plus/icons-vue";
defineOptions({ title: "头部组件" });
const props = defineProps(["res"]);
const px = (v) => `${(Number(v) || 0) / 2}px`;
// 搜索热词轮换
const hotIndex = ref(0);
let hotTimer = null;
const validHotWords = computed(() =>
(props.res?.hotWords || []).filter((w) => w && String(w).trim())
);
const displayHotWord = computed(() => {
if (validHotWords.value.length) {
return validHotWords.value[hotIndex.value % validHotWords.value.length];
}
return props.res?.placeWords || "搜索商品名称";
});
function stopHotTimer() {
if (hotTimer) {
clearInterval(hotTimer);
hotTimer = null;
}
}
watch(
() => [validHotWords.value.length, props.res?.hotInterval],
() => {
stopHotTimer();
hotIndex.value = 0;
if (validHotWords.value.length > 1) {
hotTimer = setInterval(() => {
hotIndex.value = (hotIndex.value + 1) % validHotWords.value.length;
}, (Number(props.res?.hotInterval) || 3) * 1000);
}
},
{ immediate: true }
);
// 选项卡
const activeTabIndex = ref(0);
const displayTabs = computed(() => {
const custom = Array.isArray(props.res?.tabList) ? props.res.tabList : [];
return [{ title: "首页", isHome: true }, ...custom];
});
// 轮播图
const currentSlide = ref(0);
const previewWidth = ref(375);
const cardPreview = ref(null);
let bannerTimer = null;
const bannerList = computed(() =>
Array.isArray(props.res?.list) ? props.res.list : []
);
const cardMode = computed(() => (props.res?.swiperStyle || "card") === "card");
const currentBannerImg = computed(() => {
const list = bannerList.value;
if (!list.length) return "";
const item = list[currentSlide.value % list.length] || list[0];
return item?.img || "";
});
const cardSlides = computed(() => {
const list = bannerList.value;
if (list.length <= 1) return list;
return [list[list.length - 1], ...list, list[0]];
});
const activeTrackIndex = computed(() =>
bannerList.value.length > 1 ? currentSlide.value + 1 : currentSlide.value
);
const trackOffset = computed(
() =>
previewWidth.value * 0.06 -
activeTrackIndex.value * previewWidth.value * 0.88
);
function measureCard() {
if (cardPreview.value) {
previewWidth.value = cardPreview.value.offsetWidth || 375;
}
}
function startBannerTimer() {
stopBannerTimer();
if (!cardMode.value || bannerList.value.length < 2) return;
bannerTimer = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % bannerList.value.length;
}, 4000);
}
function stopBannerTimer() {
if (bannerTimer) {
clearInterval(bannerTimer);
bannerTimer = null;
}
}
function onSlideChange(idx) {
currentSlide.value = idx;
}
watch(
() => [cardMode.value, bannerList.value.length],
() => {
if (currentSlide.value >= bannerList.value.length) {
currentSlide.value = 0;
}
stopBannerTimer();
if (cardMode.value) {
measureCard();
startBannerTimer();
}
},
{ immediate: true, flush: "post" }
);
onMounted(() => {
measureCard();
startBannerTimer();
});
onBeforeUnmount(() => {
stopHotTimer();
stopBannerTimer();
});
// 样式计算
const wrapperStyle = computed(() => ({
marginBottom: px(props.res?.pageGap ?? 0),
}));
const searchBoxStyle = computed(() => {
const r = props.res || {};
const align = r.textAlign || "left";
return {
background: r.boxColor || "#FFFFFF",
color: r.textColor || "#303133",
borderRadius: px(r.searchRadius ?? 30),
justifyContent:
align === "center"
? "center"
: align === "right"
? "flex-end"
: "flex-start",
};
});
const bannerImgStyle = computed(() => ({
borderRadius: px(props.res?.bannerRadius ?? 14),
}));
</script>
<style scoped lang="scss">
.tpl-home-comb {
position: relative;
overflow: hidden;
padding: 8px 10px 10px;
box-sizing: border-box;
}
.comb-bg {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
background: linear-gradient(180deg, #e93323 0%, #f5f5f5 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.25);
filter: blur(22px);
display: block;
}
.comb-mask {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 90px;
background: linear-gradient(180deg, rgba(245, 245, 245, 0) 0%, #f5f5f5 100%);
}
}
.comb-search-row {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.comb-logo {
width: 69px;
height: 24px;
object-fit: contain;
flex-shrink: 0;
}
.comb-search-box {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
box-sizing: border-box;
overflow: hidden;
.search-ico {
font-size: 13px;
flex-shrink: 0;
}
.search-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.capsule {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
box-sizing: border-box;
color: #303133;
.cap-icon {
display: flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.cap-divider {
width: 1px;
height: 14px;
background: rgba(0, 0, 0, 0.12);
margin: 0 8px;
}
}
.comb-tabs {
position: relative;
z-index: 2;
display: flex;
align-items: center;
height: 36px;
margin-top: 4px;
}
.comb-tabs-scroll {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.comb-tab-item {
position: relative;
flex-shrink: 0;
font-size: 13px;
line-height: 34px;
cursor: pointer;
white-space: nowrap;
&.active {
font-size: 14px;
font-weight: 600;
}
.tab-line {
position: absolute;
left: 50%;
bottom: 2px;
transform: translateX(-50%);
width: 18px;
height: 2px;
border-radius: 2px;
}
}
.comb-tab-more {
flex-shrink: 0;
padding-left: 8px;
display: flex;
align-items: center;
font-size: 14px;
}
.comb-banner {
position: relative;
z-index: 2;
margin-top: 6px;
height: 146px;
overflow: hidden;
&.is-card {
margin-left: -10px;
margin-right: -10px;
}
&.no-tabs {
margin-top: 10px;
}
}
.comb-carousel {
height: 146px;
}
.slide-wrap {
width: 100%;
height: 146px;
}
.slide-img {
width: 100%;
height: 146px;
object-fit: fill;
display: block;
}
.slide-empty {
width: 100%;
height: 146px;
background: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #909399;
}
.card-preview {
width: 100%;
height: 146px;
overflow: hidden;
display: flex;
align-items: center;
}
.card-track {
display: flex;
align-items: center;
width: 100%;
transition: transform 0.45s ease;
}
.card-slide {
flex: 0 0 88%;
padding: 0 3px;
box-sizing: border-box;
transform: scale(0.92);
transition: transform 0.35s ease;
&.active {
transform: scale(1);
}
}
.card-img {
width: 100%;
height: 146px;
object-fit: fill;
display: block;
}
.comb-indicator {
position: absolute;
bottom: 8px;
display: flex;
align-items: center;
z-index: 3;
&.pos-left {
left: 16px;
}
&.pos-center {
left: 50%;
transform: translateX(-50%);
}
&.pos-right {
right: 16px;
}
.dot {
background: rgba(255, 255, 255, 0.65);
transition: all 0.25s ease;
&.round {
width: 6px;
height: 6px;
border-radius: 50%;
margin: 0 3px;
}
&.line {
width: 12px;
height: 3px;
border-radius: 2px;
margin: 0 2px;
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="layout">
<div class="hot-area">
<!-- <img :src="res.list[0].img" alt="" /> -->
<hotzone @change="changeHotzone" :zonesInit="res.list[0].zoneInfo" :image="res.list[0].img"></hotzone>
</div>
</div>
</template>
<script setup>
import hotzone from "@/views/shop/hotzone";
const props = defineProps(["res"]);
function changeHotzone(info) {
props.res.list[0].zoneInfo = info;
}
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.hot-area {
width: 100%;
display: block;
overflow: hidden;
> img {
width: 300px;
height: 500px;
}
}
</style>

View File

@@ -1,37 +1,250 @@
<template>
<div class="layout">
<div class="join-list">
<div class="join-title">
<div>{{ res.list[0].title }}</div>
<div>更多</div>
<div class="layout" :style="wrapperStyle">
<div class="integral-head" :style="headStyle">
<img
v-if="styled && res.titleType === 'image' && res.titleImage"
class="integral-head-img"
:src="res.titleImage"
alt=""
/>
<div v-else class="integral-head-title" :style="headTitleStyle">{{ title }}</div>
<div class="integral-head-space"></div>
<div class="integral-head-more" :style="moreStyle">更多 ></div>
</div>
<!-- 老数据:原始四列样式 -->
<div v-if="!styled" class="join-box">
<div class="join-item" v-for="item in count" :key="item">
<div class="item-img-box">
<img
class="item-img"
src="https://picsum.photos/id/268/200/200"
alt
/>
</div>
<div class="item-price">
<span>20积分</span>
</div>
<div class="item-line-through">
<span>30积分</span>
</div>
</div>
<div class="join-box">
<div class="join-item" v-for="item in 4" :key="item">
<div class="item-img-box">
<img
class="item-img"
src="https://picsum.photos/id/268/200/200"
alt
/>
</div>
<div class="item-price">
<span>20积分</span>
</div>
<div class="item-line-through">
<span>30积分</span>
</div>
<!-- 样式一:两列大卡片 -->
<div v-else-if="layoutStyle === '1'" class="ig-grid2" :style="gapStyle">
<div class="ig-g2-item" v-for="(g, i) in displayData" :key="i">
<div class="ig-g2-img-box" :style="imgRadiusStyle">
<img class="ig-img" :src="g.thumbnail ? g.thumbnail : g.goodsImage" alt />
</div>
<div class="ig-g2-name" :style="nameStyle">{{ g.goodsName ? g.goodsName : g.name }}</div>
<div class="ig-price-row">
<div class="ig-price" :style="pointStyle">
<span class="ig-coin">¥</span>{{ g.points != null ? g.points : 0 }}积分
</div>
<s class="ig-original" v-if="g.originalPrice">¥{{ g.originalPrice }}</s>
</div>
</div>
</div>
<!-- 样式二:三列,积分价在名称上方 -->
<div v-else-if="layoutStyle === '2'" class="ig-grid3" :style="gapStyle">
<div class="ig-g3-item" v-for="(g, i) in displayData" :key="i">
<div class="ig-g3-img-box" :style="imgRadiusStyle">
<img class="ig-img" :src="g.thumbnail ? g.thumbnail : g.goodsImage" alt />
</div>
<div class="ig-price ig-price-sm" :style="pointStyle">
<span class="ig-coin ig-coin-sm">¥</span>{{ g.points != null ? g.points : 0 }}积分
</div>
<div class="ig-g3-name" :style="nameStyle">{{ g.goodsName ? g.goodsName : g.name }}</div>
</div>
</div>
<!-- 样式三:三列,仅积分价 -->
<div v-else class="ig-grid3" :style="gapStyle">
<div class="ig-g3-item" v-for="(g, i) in displayData" :key="i">
<div class="ig-g3-img-box" :style="imgRadiusStyle">
<img class="ig-img" :src="g.thumbnail ? g.thumbnail : g.goodsImage" alt />
</div>
<div class="ig-price ig-price-sm" :style="pointStyle">
<span class="ig-coin ig-coin-sm">¥</span>{{ g.points != null ? g.points : 0 }}积分
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps(["res"]);
import { computed } from "vue";
const INTEGRAL_DEMO = [
{
goodsName: "小米小爱音箱 Play 增强版 智能红外遥控",
points: 1200,
originalPrice: 149,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "Apple 20W USB-C 官方原装快充充电器",
points: 1500,
originalPrice: 149,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "米家自动洗手机 套装 抑菌洗手液",
points: 800,
originalPrice: 79,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "多功能桌面风扇 超长续航三档风力",
points: 600,
originalPrice: 59,
thumbnail: "/demo/vivo-phone.png",
},
{
goodsName: "精品保温杯 316不锈钢 480ml",
points: 990,
originalPrice: 99,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "无线双模鼠标 2.4G+蓝牙 轻音便携",
points: 750,
originalPrice: 69,
thumbnail: "/demo/vivo-phone.png",
},
];
const props = defineProps(["res"]);
const title = computed(
() => (props.res.list && props.res.list[0] && props.res.list[0].title) || "积分兑好礼"
);
const firstItem = computed(() => (props.res.list && props.res.list[0]) || {});
const layoutStyle = computed(() => props.res.layoutStyle || "1");
const count = computed(() => {
const c = firstItem.value?.count ?? props.res?.count;
if (c !== undefined && c !== null && !isNaN(Number(c)) && Number(c) > 0) {
return Number(c);
}
return layoutStyle.value === "1" ? 4 : 6;
});
const displayData = computed(() => {
const raw = (firstItem.value && firstItem.value.data) || [];
const data = raw.length ? raw : INTEGRAL_DEMO;
const targetCount = count.value;
if (data.length >= targetCount) {
return data.slice(0, targetCount);
}
// 如果设置的数量大于现有模拟数据长度,循环补齐,使预览数量与配置数量严格一致
const result = [...data];
while (result.length < targetCount) {
result.push({ ...data[result.length % data.length] });
}
return result.slice(0, targetCount);
});
// 面板保存过样式字段才启用新样式,老数据保持原样
const styled = computed(() => props.res && props.res.paddingTop !== undefined);
const px = (v) => `${(Number(v) || 0) / 2}px`;
const wrapperStyle = computed(() => {
const res = props.res || {};
if (!styled.value) {
return {
background: res.bgColor || "#FFFFFF",
padding: `0 ${res.paddingX || 0}px`,
marginBottom: `${res.pageGap !== undefined ? res.pageGap : 0}px`,
borderRadius: `${res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
});
const headStyle = computed(() => {
if (!styled.value) return {};
const res = props.res;
if (res.headBgType === "image" && res.headBgImage) {
return {
backgroundImage: `url(${res.headBgImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
};
}
return {
background:
!res.headBg2 || res.headBg1 === res.headBg2
? res.headBg1
: `linear-gradient(90deg, ${res.headBg1}, ${res.headBg2})`,
};
});
const headTitleStyle = computed(() => {
if (!styled.value) return {};
const res = props.res;
return {
color: res.titleColor || "#FFFFFF",
fontWeight: res.titleStyle === "bold" ? "bold" : "normal",
fontStyle: res.titleStyle === "italic" ? "italic" : "normal",
};
});
const moreStyle = computed(() =>
styled.value ? { color: props.res.moreColor || "#999999" } : {}
);
const pointStyle = computed(() =>
styled.value && props.res.pointColor ? { color: props.res.pointColor } : {}
);
const nameStyle = computed(() =>
styled.value && props.res.nameColor ? { color: props.res.nameColor } : {}
);
const gapStyle = computed(() => {
if (!styled.value) return {};
const gap = px(props.res.contentGap ?? 10);
return { gap, "--ig-gap": gap };
});
const imgRadiusStyle = computed(() =>
styled.value ? { borderRadius: px(props.res.imgRadius), overflow: "hidden" } : {}
);
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.integral-head {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-height: 48px;
padding: 0 12px;
box-sizing: border-box;
margin-bottom: 10px;
border-radius: 6px;
}
.integral-head-img {
width: 77px;
height: 16px;
object-fit: contain;
}
.integral-head-title {
font-size: 16px;
font-weight: bold;
}
.integral-head-space {
flex: 1;
}
.integral-head-more {
flex-shrink: 0;
font-size: 12px;
color: #999;
}
.join-box {
display: flex;
}
@@ -44,12 +257,7 @@ defineProps(["res"]);
}
.join-item {
flex: 1;
}
.item-img {
width: 75px;
height: 75px;
margin: 0 auto;
display: block;
text-align: center;
}
.item-img-box {
position: relative;
@@ -62,26 +270,114 @@ defineProps(["res"]);
color: #999;
}
}
.item-position-tips {
position: absolute;
right: 0;
color: #fff;
font-size: 12px;
bottom: 0;
.item-img {
width: 75px;
height: 75px;
margin: 0 auto;
display: block;
}
.join-title {
/* 积分商品卡片(参考 CRMEB 积分商城) */
.ig-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.ig-grid2 {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
height: 50px;
> div:nth-of-type(1) {
font-size: 16px;
font-weight: bold;
}
> div:nth-of-type(2) {
font-size: 12px;
color: #999;
}
flex-wrap: wrap;
}
</style>
.ig-g2-item {
width: calc((100% - var(--ig-gap, 5px)) / 2);
background: #fff;
border-radius: 8px;
overflow: hidden;
padding-bottom: 8px;
box-sizing: border-box;
}
.ig-g2-img-box {
width: 100%;
height: 150px;
background: #f5f5f5;
}
.ig-g2-name {
margin: 8px 8px 0;
font-size: 13px;
color: #282828;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ig-price-row {
display: flex;
align-items: baseline;
margin: 5px 8px 0;
min-width: 0;
}
.ig-price {
display: flex;
align-items: center;
font-size: 14px;
font-weight: bold;
color: #f5483a;
white-space: nowrap;
}
.ig-price-sm {
font-size: 12px;
margin-top: 5px;
}
.ig-coin {
width: 14px;
height: 14px;
border-radius: 50%;
background: linear-gradient(135deg, #ffe29a, #f5a623);
color: #fff;
font-size: 9px;
font-weight: bold;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 3px;
flex-shrink: 0;
}
.ig-coin-sm {
width: 12px;
height: 12px;
font-size: 8px;
}
.ig-original {
font-size: 11px;
color: #999;
margin-left: 5px;
white-space: nowrap;
}
.ig-grid3 {
display: flex;
flex-wrap: wrap;
}
.ig-g3-item {
width: calc((100% - var(--ig-gap, 5px) * 2) / 3);
background: #fff;
border-radius: 8px;
overflow: hidden;
padding-bottom: 8px;
box-sizing: border-box;
}
.ig-g3-item .ig-price {
margin: 5px 8px 0;
}
.ig-g3-img-box {
width: 100%;
height: 92px;
background: #f5f5f5;
}
.ig-g3-name {
margin: 4px 8px 0;
font-size: 12px;
color: #282828;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div class="layout">
<div class="view-height-150">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-height-150" :style="imgStyle">
<img alt="模板图片" class="image-mode" :src="res.list[0].img" />
</div>
<div class="view-height-150">
<div class="view-height-75">
<div class="view-height-75" :style="imgStyle">
<img class="image-mode" :src="res.list[1].img" alt />
</div>
<div class="view-height-75">
<div class="view-height-75" :style="imgStyle">
<img class="image-mode" :src="res.list[2].img" alt />
</div>
</div>
@@ -16,7 +16,15 @@
</template>
<script setup>
defineProps(["res"]);
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "左一右二" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -27,5 +35,8 @@ defineProps(["res"]);
background-size: cover;
}
.view-height-150 {
min-width: 0;
}
</style>

View File

@@ -1,22 +1,30 @@
<template>
<div class="layout ">
<div class="layout " :style="[wrapperStyle, gapStyle]">
<div class="view-height-150">
<div class="view-height-75">
<div class="view-height-75" :style="imgStyle">
<img class="image-mode" :src="res.list[0].img" alt />
</div>
<div class="view-height-75">
<div class="view-height-75" :style="imgStyle">
<img class="image-mode" :src="res.list[1].img" alt />
</div>
</div>
<div class="view-height-150">
<div class="view-height-150" :style="imgStyle">
<img alt="模板图片" class="image-mode" style="height:150px;" :src="res.list[2].img" />
</div>
</div>
</template>
<script setup>
defineProps(["res"]);
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "左二右一" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -28,6 +36,9 @@ defineProps(["res"]);
background-size: cover;
}
.view-height-150 {
min-width: 0;
}
.view-height-75 {
.image-mode {
height: 75px;

View File

@@ -0,0 +1,41 @@
<template>
<div
class="tpl-line"
:style="{
background: lineBg,
padding: `0 ${px(res.paddingX ?? 0)}`,
marginBottom: px(res.pageGap ?? 0),
}"
>
<div
:style="{
borderTop: `${Math.max(1, (Number(res.lineHeight ?? 1) || 1) / 2)}px ${res.lineStyle ?? 'dashed'} ${res.lineColor ?? '#979797'}`,
}"
></div>
</div>
</template>
<script>
const px = (v) => `${(Number(v) || 0) / 2}px`;
export default {
name: "tpl_line",
props: {
res: { type: Object, default: () => ({}) },
},
methods: {
px,
},
computed: {
lineBg() {
const res = this.res || {};
if (res.bgColor1 !== undefined) {
return !res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
}
return res.bgColor || "#FFFFFF";
},
},
};
</script>

View File

@@ -1,31 +1,86 @@
<template>
<div class="layout">
<div class="menu-list">
<div class="menu-item" v-for="(item, index) in res.list" :key="index">
<div>
<div class="layout" :style="wrapperStyle">
<div
class="menu-list"
:class="{ styled: styled, single: isSingle }"
:style="listStyle"
>
<div
class="menu-item"
:style="itemStyle"
v-for="(item, index) in visibleList"
:key="index"
>
<div class="menu-img-wrap" :style="imgStyle">
<img class="menu-img" :src="item.img" alt="" />
</div>
<div class="menu-title">{{ item.title }}</div>
<div class="menu-title" :style="titleStyle">{{ item.title }}</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps(["res"]);
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle } from "./cubeStyle";
const props = defineProps(["res"]);
// 面板保存过样式字段才启用自定义样式,老数据保持原样
const styled = computed(() => props.res && props.res.paddingTop !== undefined);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const listStyle = computed(() =>
styled.value ? { rowGap: `${(Number(props.res.contentGap) || 0) / 2}px` } : {}
);
const titleStyle = computed(() =>
styled.value && props.res.textColor ? { color: props.res.textColor } : {}
);
// 展示设置:单行/多行、行数、每行个数,超出部分不展示
const isSingle = computed(
() => styled.value && props.res.menuDisplay === "single"
);
const cols = computed(() => (styled.value ? Number(props.res.menuCols) || 4 : 4));
const rows = computed(() => Number(props.res.menuRows) || 3);
const itemStyle = computed(() =>
styled.value ? { flex: `0 0 ${100 / cols.value}%` } : {}
);
const visibleList = computed(() => {
const list = props.res.list || [];
if (!styled.value) return list;
const limit = isSingle.value ? cols.value : rows.value * cols.value;
return list.slice(0, limit);
});
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.menu-list {
display: flex;
align-items: center;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
&.single {
flex-wrap: nowrap;
overflow: hidden;
}
> .menu-item {
text-align: center;
width: 20%;
width: 25%;
margin: 10px 0;
}
&.styled > .menu-item,
&.single > .menu-item {
margin: 0;
width: auto;
}
}
.menu-img-wrap {
width: 44px;
height: 44px;
margin: 0 auto;
overflow: hidden;
}
.menu-img {
width: 44px;

View File

@@ -0,0 +1,123 @@
<template>
<div class="layout" :class="{ styled: styled }" :style="wrapperStyle" v-if="texts.length">
<div class="background" :style="barStyle">
<img
v-if="styled && item.img"
class="notice-icon"
:src="item.img"
alt=""
/>
<div class="title" :style="{ color: item.color, textAlign: alignValue }">
{{ context }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from "vue";
defineOptions({ title: "公告" });
const props = defineProps(["res"]);
const item = computed(() => props.res?.list?.[0] || {});
const texts = computed(() => {
const titles = item.value?.title;
if (!Array.isArray(titles)) return [];
return titles.filter((t) => t && t.context != null && String(t.context).length > 0);
});
// 面板保存过样式字段才启用自定义样式,老数据保持原样
const styled = computed(() => props.res && props.res.paddingX !== undefined);
const px = (v) => `${(Number(v) || 0) / 2}px`;
const wrapperStyle = computed(() => {
const res = props.res || {};
if (!styled.value) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
});
const barStyle = computed(() =>
styled.value ? {} : { backgroundColor: item.value.bk_color }
);
const alignValue = computed(() =>
styled.value ? item.value.textAlign || "left" : "left"
);
const index = ref(0);
const context = ref("");
let timer = null;
watch(
texts,
(list) => {
if (timer) {
clearInterval(timer);
timer = null;
}
index.value = 0;
context.value = list.length ? String(list[0].context) : "";
if (list.length > 1) {
timer = setInterval(() => {
index.value = (index.value + 1) % list.length;
context.value = String(list[index.value].context);
}, 3000);
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.background {
width: 100%;
height: 42px;
text-align: left;
font-size: 10px;
background-size: cover;
display: flex;
align-items: center;
}
.layout {
text-align: center;
position: relative;
height: 42px;
}
.layout.styled {
height: auto;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.notice-icon {
width: 65px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
margin: 0 8px;
}
.title {
flex: 1;
min-width: 0;
line-height: 42px;
font-size: 10px;
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
<template>
<div class="layout">
<div class="flex-two">
请选择促销活动(最多只能展示两个活动)
</div>
</div>
</template>
<script setup>
defineOptions({
title: "两张横图",
});
defineProps(["res"]);
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.flex-two {
width: 100%;
display: flex;
height: 110px;
overflow: hidden;
}
.flex-item {
width: 50%;
> img {
display: block;
max-width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div
class="tpl-rich-text"
:style="{
background: richBg,
padding: `0 ${px(res.paddingX ?? 12)}`,
marginBottom: px(res.pageGap ?? 0),
borderRadius: px(res.borderRadius ?? 0),
overflow: 'hidden',
}"
>
<div class="rich-content" v-html="res.content ?? ''"></div>
</div>
</template>
<script>
const px = (v) => `${(Number(v) || 0) / 2}px`;
export default {
name: "tpl_rich_text",
props: {
res: { type: Object, default: () => ({}) },
},
methods: {
px,
},
computed: {
richBg() {
const res = this.res || {};
if (res.bgColor1 !== undefined) {
return !res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
}
return res.bgColor || "#FFFFFF";
},
},
};
</script>
<style scoped lang="scss">
.rich-content {
:deep(img) {
max-width: 100%;
height: auto;
}
}
</style>

View File

@@ -1,25 +1,144 @@
<template>
<div class="layout">
<div class="search">
<el-icon><Search /></el-icon>{{res.list[0].title}}
</div>
<div class="layout" :style="wrapperStyle">
<div class="search" :style="boxStyle">
<el-icon><Search /></el-icon>{{ displayText }}
</div>
<!-- 微信小程序右上角胶囊按钮 -->
<div class="capsule">
<span class="cap-icon">
<svg width="18" height="5" viewBox="0 0 18 5" fill="currentColor">
<circle cx="2.5" cy="2.5" r="2" />
<circle cx="9" cy="2.5" r="2" />
<circle cx="15.5" cy="2.5" r="2" />
</svg>
</span>
<span class="cap-divider"></span>
<span class="cap-icon">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="7.5" cy="7.5" r="6" stroke="currentColor" stroke-width="1.6" />
<circle cx="7.5" cy="7.5" r="2.2" fill="currentColor" />
</svg>
</span>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, onBeforeUnmount } from "vue";
import { Search } from "@element-plus/icons-vue";
defineProps(['res']);
const props = defineProps(["res"]);
// 搜索热词轮播:有热词时按切换时间轮换展示,否则展示提示文字
const hotIndex = ref(0);
let hotTimer = null;
const validWords = computed(() =>
(props.res.hotWords || []).filter((w) => w && w.trim())
);
const displayText = computed(() => {
if (validWords.value.length) {
return validWords.value[hotIndex.value % validWords.value.length];
}
return (props.res.list && props.res.list[0] && props.res.list[0].title) || "";
});
function stopHot() {
if (hotTimer) {
clearInterval(hotTimer);
hotTimer = null;
}
}
watch(
() => [validWords.value.length, props.res.hotInterval],
() => {
stopHot();
hotIndex.value = 0;
if (validWords.value.length > 1) {
hotTimer = setInterval(() => {
hotIndex.value = (hotIndex.value + 1) % validWords.value.length;
}, (Number(props.res.hotInterval) || 3) * 1000);
}
},
{ immediate: true }
);
onBeforeUnmount(stopHot);
// 面板保存过样式字段才启用自定义样式,老数据保持原样
const styled = computed(() => props.res && props.res.paddingX !== undefined);
// 画布 375px ≈ 750rpx,预览按一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
const wrapperStyle = computed(() => {
const res = props.res || {};
if (!styled.value) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
padding: `0 ${px(res.paddingX)}`,
background: bg,
borderRadius: px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
});
const boxStyle = computed(() => {
const res = props.res || {};
if (!styled.value) return {};
return {
background: res.boxColor || "#EDEDED",
color: res.textColor || "#999999",
borderRadius: px(res.boxRadius),
justifyContent: res.textAlign === "center" ? "center" : "flex-start",
};
});
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.search{
height: 32px;
border-radius: 5px;
.search {
flex: 1;
min-width: 0;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
background: #ededed;
gap: 4px;
box-sizing: border-box;
padding: 0 8px;
}
.layout {
background: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.capsule {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
box-sizing: border-box;
color: #303133;
.cap-icon {
display: flex;
align-items: center;
justify-content: center;
background: #ededed;
svg {
display: block;
}
}
.cap-divider {
width: 1px;
height: 16px;
background: rgba(0, 0, 0, 0.12);
margin: 0 10px;
}
}
.layout{
background: #fff;
}
</style>
</style>

View File

@@ -0,0 +1,513 @@
<template>
<div class="tpl-shop" :style="wrapperStyle">
<div class="shop-head" v-if="headIcon || title || hasMore">
<img v-if="headIcon" class="shop-head-icon" :src="headIcon" alt="" />
<div class="shop-title" :style="titleStyle">{{ title }}</div>
<div v-if="hasMore" class="shop-more" :style="moreStyle">更多 ></div>
</div>
<!-- 老数据:两列网格 -->
<div v-if="!styled" class="shop-grid-legacy">
<div class="shop-item" v-for="i in 4" :key="i">
<div class="shop-logo"></div>
<div class="shop-name">店铺名称</div>
</div>
</div>
<!-- 样式一:店铺卡片三列(全景大片橱窗) -->
<div v-else-if="shopStyle === 'one'" class="shop-grid">
<div class="shop-card" v-for="(shop, i) in previewShops" :key="i">
<img
v-if="shop.goodsImage"
class="shop-card-bg"
:style="logoStyle"
:src="shop.goodsImage"
alt=""
/>
<div v-else class="shop-card-bg shop-card-bg-empty" :style="logoStyle"></div>
<div class="shop-card-badge" v-if="isSelf(shop)">自营</div>
<div class="shop-card-body">
<img v-if="showLogo" class="shop-card-logo" :src="shop.logo" alt="" />
<div v-if="showName" class="shop-card-name" :style="nameStyle">{{ shop.name }}</div>
</div>
</div>
</div>
<!-- 样式二:店铺卡片 + 商品列表(大牌名店街 + 精品橱窗) -->
<div v-else class="shop-street-list">
<div class="shop-street-card" v-for="(shop, i) in previewShops" :key="i">
<div class="shop-street-head">
<div class="shop-street-info">
<img v-if="showLogo" class="shop-street-logo" :src="shop.logo" alt="" />
<div class="shop-street-meta">
<div class="shop-street-name-row">
<div v-if="showName" class="shop-street-name" :style="nameStyle">{{ shop.name }}</div>
<span class="shop-street-tag" v-if="isSelf(shop)">自营</span>
</div>
<div class="shop-street-desc">热卖好物甄选中</div>
</div>
</div>
<div class="shop-street-enter">进店逛逛 &gt;</div>
</div>
<div class="shop-street-goods">
<div
class="street-goods-item"
v-for="(goods, goodsIndex) in shop.goods"
:key="goodsIndex"
>
<div class="street-goods-cover">
<img class="street-goods-img" :src="goods.img" alt="" />
</div>
<div class="street-goods-name">{{ goods.name }}</div>
<div class="street-goods-price">
<span class="street-goods-symbol">¥</span>{{ $filters.unitPrice(goods.price) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { getGoodsListData } from "@/api/goods";
export default {
name: "tpl_shop",
props: {
res: { type: Object, default: () => ({}) },
},
data() {
return {
goodsMap: {},
};
},
computed: {
first() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return this.first.title || "";
},
headIcon() {
return this.first.icon || "";
},
hasMore() {
return !!this.first.moreUrl;
},
showLogo() {
return this.first.showLogo !== false;
},
showName() {
return this.first.showName !== false;
},
shopStyle() {
return this.first.shopStyle || "one";
},
// 自定义模式展示已选店铺,默认模式用示例店铺
previewShops() {
const DEMO = [
{
name: "Apple",
logo: "/demo/apple.png",
goodsImage: "/demo/apple-phone.png",
goods: [
{ name: "iPhone 16 Pro", price: 8999, img: "/demo/apple-phone.png" },
{ name: "iPhone 16", price: 7999, img: "/demo/apple-phone.png" },
{ name: "iPhone 15", price: 6999, img: "/demo/apple-phone.png" },
],
},
{
name: "小米",
logo: "/demo/xiaomi.png",
goodsImage: "/demo/xiaomi-phone.png",
goods: [
{ name: "Xiaomi 15 Pro", price: 5299, img: "/demo/xiaomi-phone.png" },
{ name: "Xiaomi 15", price: 3999, img: "/demo/xiaomi-phone.png" },
{ name: "Xiaomi 14", price: 3299, img: "/demo/xiaomi-phone.png" },
],
},
{
name: "VIVO",
logo: "/demo/vivo.png",
goodsImage: "/demo/vivo-phone.png",
goods: [
{ name: "vivo X200 Pro", price: 5299, img: "/demo/vivo-phone.png" },
{ name: "vivo X200", price: 4299, img: "/demo/vivo-phone.png" },
{ name: "vivo X100 Pro", price: 3999, img: "/demo/vivo-phone.png" },
],
},
];
if (this.first.dataMode === "custom" && this.first.shopList && this.first.shopList.length) {
return this.first.shopList.map((s) => {
const goods = this.goodsMap[s.id] || [];
return {
id: s.id,
name: s.storeName,
logo: s.storeLogo,
selfOperated: s.selfOperated,
goods,
goodsImage: goods[0]?.img || s.goodsImage || s.storeBanner || s.storeLogo || "",
};
});
}
return DEMO;
},
// 面板保存过样式字段才启用自定义样式,老数据保持原样
styled() {
return this.res && this.res.paddingTop !== undefined;
},
titleStyle() {
return this.styled && this.res.titleColor
? { color: this.res.titleColor }
: {};
},
nameStyle() {
return this.styled && this.res.nameColor
? { color: this.res.nameColor }
: {};
},
moreStyle() {
return this.styled && this.res.moreColor
? { color: this.res.moreColor }
: {};
},
logoStyle() {
return this.styled
? { borderRadius: `${(Number(this.res.imgRadius) || 0) / 2}px` }
: {};
},
wrapperStyle() {
if (!this.styled) {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const res = this.res;
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
},
},
watch: {
"first.dataMode": "loadGoodsImages",
"first.shopList": {
handler: "loadGoodsImages",
deep: true,
},
},
created() {
this.loadGoodsImages();
},
methods: {
isSelf(shop) {
if (!shop) return false;
return Boolean(
shop.selfOperated === true ||
shop.selfOperated === 'true' ||
shop.selfOperated === 1 ||
shop.selfOperated === '1' ||
(shop.name && shop.name.includes('自营')) ||
(shop.storeName && shop.storeName.includes('自营'))
);
},
loadGoodsImages() {
if (this.first.dataMode !== "custom") return;
(this.first.shopList || []).forEach((shop) => {
if (!shop.id || this.goodsMap[shop.id]) return;
getGoodsListData({
storeId: shop.id,
pageNumber: 1,
pageSize: 3,
goodsStatus: "UPPER",
})
.then((res) => {
const records = (res && res.result && res.result.records) || [];
this.goodsMap[shop.id] = records
.map((goods) => ({
name: goods.goodsName,
price: goods.price,
img: goods.thumbnail || goods.small || goods.original,
}))
.filter((goods) => goods.img);
})
.catch(() => {});
});
},
},
};
</script>
<style scoped lang="scss">
.shop-head {
display: flex;
align-items: center;
padding: 4px 12px 10px;
}
.shop-head-icon {
width: 62px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
margin-right: 6px;
}
.shop-title {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-more {
flex-shrink: 0;
font-size: 12px;
color: #999;
}
.shop-grid-legacy {
display: flex;
flex-wrap: wrap;
padding: 0 12px;
}
.shop-item {
width: 50%;
display: flex;
align-items: center;
margin-bottom: 10px;
}
.shop-logo {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f0f0f0;
flex-shrink: 0;
}
.shop-name {
font-size: 13px;
color: #333;
margin-left: 8px;
}
/* 样式一:全景大片橱窗 */
.shop-grid {
display: flex;
gap: 8px;
padding: 0 12px;
}
.shop-card {
position: relative;
flex: 1;
min-width: 0;
height: 112px;
border-radius: 8px;
overflow: hidden;
background: #f7f7f7;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.shop-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shop-card-bg-empty {
background: linear-gradient(135deg, #f5f5f5, #e8e8e8);
}
.shop-card-badge {
position: absolute;
top: 5px;
right: 5px;
z-index: 2;
font-size: 10px;
color: #fff;
background: #e1251b;
padding: 1px 5px;
border-radius: 4px;
line-height: 1.3;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.shop-card-body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 16px 6px 6px;
display: flex;
align-items: center;
gap: 4px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.25) 35%, rgba(0, 0, 0, 0.72) 100%);
}
.shop-card-logo {
flex-shrink: 0;
display: block;
width: 20px;
height: 20px;
border-radius: 3px;
object-fit: contain;
background: #fff;
padding: 1px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.shop-card-name {
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
/* 样式二:大牌名店街 + 精品橱窗 */
.shop-street-list {
padding: 0 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.shop-street-card {
border-radius: 10px;
overflow: hidden;
background: #fbfbfb;
border: 1px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.shop-street-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #fff;
border-bottom: 1px solid #f5f5f5;
}
.shop-street-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.shop-street-logo {
flex-shrink: 0;
width: 32px;
height: 32px;
padding: 2px;
box-sizing: border-box;
object-fit: contain;
background: #fff;
border-radius: 50%;
border: 1px solid #eee;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.shop-street-meta {
min-width: 0;
flex: 1;
}
.shop-street-name-row {
display: flex;
align-items: center;
gap: 5px;
}
.shop-street-name {
font-size: 13px;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-street-tag {
flex-shrink: 0;
font-size: 9px;
color: #fff;
background: #e1251b;
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
font-weight: 500;
}
.shop-street-desc {
font-size: 10px;
color: #999;
margin-top: 2px;
}
.shop-street-enter {
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
color: $theme_color;
background: #fff;
border: 1px solid rgba(255, 60, 42, 0.25);
padding: 3px 8px;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(255, 60, 42, 0.05);
cursor: pointer;
margin-left: 8px;
}
.shop-street-goods {
display: flex;
gap: 8px;
padding: 8px;
box-sizing: border-box;
}
.street-goods-item {
flex: 1;
min-width: 0;
border-radius: 6px;
overflow: hidden;
background: #fff;
border: 1px solid #f2f2f2;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.street-goods-cover {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: #fafafa;
}
.street-goods-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.street-goods-name {
padding: 4px 6px 0;
color: #333;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.street-goods-price {
padding: 2px 6px 6px;
color: $theme_color;
font-size: 12px;
font-weight: 700;
}
.street-goods-symbol {
font-size: 9px;
font-weight: normal;
margin-right: 1px;
}
</style>

View File

@@ -1,30 +1,30 @@
<template>
<div class="layout">
<div class="view-list">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-list" :style="gapStyle">
<div class="view-item">
<div class="-item-tilte">{{res.list[0].title}}</div>
<div class="-item-image">
<div class="-item-image" :style="imgStyle">
<img :src="res.list[0].img" alt />
</div>
</div>
<div class="view-item">
<div class="-item-tilte">{{res.list[1].title}}</div>
<div class="-item-image">
<div class="-item-image" :style="imgStyle">
<img :src="res.list[1].img" alt />
</div>
</div>
</div>
<div class="view-list">
<div class="view-list" :style="gapStyle">
<div class="view-item">
<div class="-item-tilte">{{res.list[2].title}}</div>
<div class="-item-image">
<div class="-item-image" :style="imgStyle">
<img :src="res.list[2].img" alt />
</div>
</div>
<div class="view-item">
<div class="-item-tilte">{{res.list[3].title}}</div>
<div class="-item-image">
<div class="-item-image" :style="imgStyle">
<img :src="res.list[3].img" alt />
</div>
</div>
@@ -33,8 +33,15 @@
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "文字图片模板" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -73,7 +80,8 @@ defineProps(["res"]);
border: 1px solid #ededed;
> .view-item {
width: 50%;
flex: 1;
min-width: 0;
}
> .view-item:nth-of-type(1) {
> .-item-tilte {

View File

@@ -1,58 +1,132 @@
<template>
<div class="layout" :style="{ textAlign: res.list[0].textAlign }">
<div class="background" :style="{ backgroundColor: res.list[0].bk_color }">
<div class="title" :style="{ color: res.list[0].color }">
{{ res.list[0].title }}
<div class="layout" :style="wrapperStyle">
<!-- 新样式:左标题+副标题,右侧更多 -->
<div v-if="styled" class="title-bar" :style="barStyle">
<div class="title-left">
<span class="title-main" :style="mainStyle">{{ item.title }}</span>
<span class="title-sub" :style="subStyle" v-if="item.title1">{{ item.title1 }}</span>
</div>
<div
style="
position: absolute;
right: 10px;
top: 2px;
color: #fff;
line-height: 42px;
font-size: 10px;
"
>
<a
:href="res.list[0].url"
:style="{ color: res.list[0].color1 }"
style="text-decoration: none"
>{{ res.list[0].title1 }}</a
>
<div v-if="item.showMore" class="title-more" :style="moreStyle">
{{ item.moreText || "更多" }} >
</div>
</div>
<!-- 老数据:保持原样 -->
<div
v-else
class="background"
:style="{ backgroundColor: item.bk_color, textAlign: item.textAlign }"
>
<div class="title" :style="{ color: item.color }">{{ item.title }}</div>
<div class="legacy-sub">
<a :style="{ color: item.color1 }" style="text-decoration: none">{{ item.title1 }}</a>
</div>
</div>
</div>
</template>
<script setup>
defineOptions({
title: "标题栏",
});
import { computed } from "vue";
defineProps(["res"]);
defineOptions({ title: "标题栏" });
const props = defineProps(["res"]);
const item = computed(() => props.res?.list?.[0] || {});
// 面板保存过样式字段才启用新样式,老数据保持原样
const styled = computed(() => props.res && props.res.paddingTop !== undefined);
const px = (v) => `${(Number(v) || 0) / 2}px`;
const wrapperStyle = computed(() => {
const res = props.res || {};
if (!styled.value) return {};
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: `${px(res.radiusTop)} ${px(res.radiusTop)} ${px(res.radiusBottom)} ${px(res.radiusBottom)}`,
overflow:
Number(res.radiusTop) > 0 || Number(res.radiusBottom) > 0 ? "hidden" : "visible",
background: "#ffffff",
boxSizing: "border-box",
};
});
const barStyle = computed(() => {
const it = item.value;
if (it.bgType === "image" && it.bgImage) {
return {
backgroundImage: `url(${it.bgImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
};
}
return { backgroundColor: it.bk_color || "#FFFFFF" };
});
const mainStyle = computed(() => ({
color: item.value.color || "#282828",
fontSize: `${Number(item.value.fontSize) || 20}px`,
fontWeight: item.value.textStyle === "bold" ? "bold" : "normal",
fontStyle: item.value.textStyle === "italic" ? "italic" : "normal",
}));
const subStyle = computed(() => ({
color: item.value.color1 || "#282828",
fontSize: `${Number(item.value.subFontSize) || 14}px`,
}));
const moreStyle = computed(() => ({
color: item.value.moreColor || "#282828",
fontSize: `${Number(item.value.moreFontSize) || 12}px`,
}));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.layout {
background: #ffffff;
}
.title-bar {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
padding: 0 12px;
box-sizing: border-box;
}
.title-left {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
overflow: hidden;
}
.title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-sub {
margin-left: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-more {
flex-shrink: 0;
}
.background {
background-color: rgb(255, 87, 62);
/*background: url("../../../../assets/title.png") no-repeat;*/
position: absolute;
position: relative;
z-index: 2;
width: 100%;
height: 42px;
background-size: cover;
}
.layout {
text-align: center;
position: relative;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.legacy-sub {
position: absolute;
right: 10px;
top: 2px;
color: #fff;
line-height: 42px;
font-size: 10px;
}
.title {
line-height: 42px;

View File

@@ -1,14 +1,14 @@
<template>
<div class="layout">
<div class="view-width-100">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-width-100" :style="imgStyle">
<img alt="模板图片" class="image-mode" :src="res.list[0].img" />
</div>
<div class="view-width-100">
<div class="view-height-85">
<div class="view-width-100" :style="gapStyle">
<div class="view-height-85" :style="imgStyle">
<img class="image-mode" :src="res.list[1].img" alt />
</div>
<div class="view-height-85">
<div class="view-height-85" :style="imgStyle">
<img class="image-mode" :src="res.list[2].img" alt />
</div>
</div>
@@ -16,8 +16,15 @@
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "上一下二" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -26,7 +33,6 @@ defineProps(["res"]);
align-items: center;
justify-content: center;
background-size: cover;
height: 170px;
flex-direction: column;
}
img {
@@ -40,6 +46,7 @@ img {
}
.view-height-85 {
padding: 0 1px;
width: 50%;
flex: 1;
min-width: 0;
}
</style>
</style>

View File

@@ -1,22 +1,29 @@
<template>
<div class="layout">
<div class="view-width-100">
<div class="view-height-85">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-width-100" :style="gapStyle">
<div class="view-height-85" :style="imgStyle">
<img class="image-mode" :src="res.list[0].img" alt />
</div>
<div class="view-height-85">
<div class="view-height-85" :style="imgStyle">
<img class="image-mode" :src="res.list[1].img" alt />
</div>
</div>
<div class="view-width-100">
<div class="view-width-100" :style="imgStyle">
<img alt="模板图片" class="image-mode" :src="res.list[2].img" />
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "上二下一" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -25,7 +32,6 @@ defineProps(["res"]);
align-items: center;
justify-content: center;
background-size: cover;
height: 170px;
flex-direction: column;
}
img {
@@ -40,6 +46,7 @@ img {
}
.view-height-85 {
padding: 0 1px;
width: 50%;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="layout" :style="wrapperStyle">
<div class="video-shell" :style="boxStyle">
<div
class="video-ratio"
:style="{ paddingBottom: aspectPadding + '%' }"
>
<div class="video-ratio-inner">
<video
v-if="item.videoUrl"
class="video-media"
:src="item.videoUrl"
:poster="item.poster"
muted
/>
<template v-else>
<img
v-if="item.poster"
class="video-media"
:src="item.poster"
alt=""
/>
<div v-else class="video-placeholder">视频楼层</div>
</template>
<div class="play-icon">▶</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import {
ensureVideoItem,
getVideoWrapperStyle,
getVideoBoxStyle,
getAspectPadding,
} from "../video-style";
const props = defineProps(["res"]);
const item = computed(() => {
const data = props.res.list[0] || {};
ensureVideoItem(data);
return data;
});
// 面板保存过样式字段(options 级)才启用新样式,老数据走 item.style 体系
const styled = computed(() => props.res && props.res.paddingTop !== undefined);
// 画布 375px ≈ 750rpx,预览按一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
const wrapperStyle = computed(() => {
const res = props.res || {};
if (!styled.value) return getVideoWrapperStyle(item.value);
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
boxSizing: "border-box",
};
});
const boxStyle = computed(() => {
if (!styled.value) return getVideoBoxStyle(item.value);
return {
borderRadius: px(props.res.imgRadius),
overflow: "hidden",
position: "relative",
width: "100%",
};
});
const aspectPadding = computed(() => getAspectPadding(item.value.aspectRatio));
</script>
<style lang="scss" scoped>
.layout {
padding: 0;
margin: 0;
background: transparent;
}
.video-shell {
width: 100%;
}
.video-ratio {
width: 100%;
height: 0;
position: relative;
}
.video-ratio-inner {
position: absolute;
inset: 0;
}
.video-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: #000;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
background: #f5f5f5;
}
.play-icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
line-height: 44px;
text-align: center;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
font-size: 18px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="video-config">
<el-tabs v-model="activeTab" class="video-config-tabs">
<el-tab-pane label="内容设置" name="content">
<div class="video-config-section-title">内容设置</div>
<div class="video-config-row">
<div class="video-config-label">上传视频</div>
<div
class="video-upload-box"
@click="$emit('select-file', 'videoUrl')"
>
<video
v-if="item.videoUrl"
class="video-upload-preview"
:src="item.videoUrl"
muted
/>
<el-icon v-else class="video-upload-plus" :size="28">
<Plus />
</el-icon>
</div>
</div>
<div class="video-config-row">
<div class="video-config-label">视频封面</div>
<div
class="video-upload-box"
@click="$emit('select-file', 'poster')"
>
<img
v-if="item.poster"
class="video-upload-preview"
:src="item.poster"
alt=""
/>
<el-icon v-else class="video-upload-plus" :size="28">
<Plus />
</el-icon>
</div>
</div>
<div class="video-config-row">
<div class="video-config-label">视频比例</div>
<el-radio-group v-model="item.aspectRatio" class="video-ratio-group">
<el-radio value="16:9">16:9</el-radio>
<el-radio value="4:3">4:3</el-radio>
<el-radio value="1:1">1:1</el-radio>
</el-radio-group>
</div>
<div class="video-config-row">
<div class="video-config-label">自动播放</div>
<el-switch v-model="item.autoplay" />
</div>
</el-tab-pane>
<el-tab-pane label="样式设置" name="style">
<div class="video-config-section-title">通用样式</div>
<div class="video-config-block">
<div class="video-config-block-title">背景设置</div>
<div class="video-config-row">
<el-radio-group v-model="item.style.bgType">
<el-radio value="color">颜色</el-radio>
<el-radio value="image">图片</el-radio>
</el-radio-group>
</div>
<template v-if="item.style.bgType === 'color'">
<div class="video-config-row">
<div class="video-config-label">背景颜色</div>
<div class="video-color-pair">
<el-color-picker v-model="item.style.bgColorStart" />
<el-color-picker v-model="item.style.bgColorEnd" />
<el-button link type="primary" @click="resetBgColors">重置</el-button>
</div>
</div>
<div class="video-config-row">
<div class="video-config-label">渐变方向</div>
<el-radio-group v-model="item.style.bgGradientDir">
<el-radio value="horizontal">横向</el-radio>
<el-radio value="vertical">纵向</el-radio>
<el-radio value="skewLeft">左斜</el-radio>
<el-radio value="skewRight">右斜</el-radio>
</el-radio-group>
</div>
</template>
<template v-else>
<div class="video-config-row">
<div class="video-config-label">背景图片</div>
<div
class="video-upload-box video-upload-box--small"
@click="$emit('select-file', 'bgImage')"
>
<img
v-if="item.style.bgImage"
class="video-upload-preview"
:src="item.style.bgImage"
alt=""
/>
<el-icon v-else class="video-upload-plus" :size="24">
<Plus />
</el-icon>
</div>
</div>
</template>
<div class="video-config-row">
<div class="video-config-label">背景圆角</div>
<el-slider
v-model="item.style.bgRadius"
:min="0"
:max="40"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">底部背景</div>
<div class="video-color-pair">
<el-color-picker v-model="item.style.bottomBgColor" />
<el-button link type="primary" @click="item.style.bottomBgColor = '#F5F5F5'">
重置
</el-button>
</div>
</div>
</div>
<div class="video-config-block">
<div class="video-config-row">
<div class="video-config-label">外边距</div>
<el-slider
v-model="item.style.margin"
:min="0"
:max="40"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">内边距</div>
<el-slider
v-model="item.style.padding"
:min="0"
:max="40"
show-input
:show-input-controls="false"
/>
</div>
</div>
<div class="video-config-block">
<div class="video-config-block-title">边框设置</div>
<div class="video-config-row">
<el-radio-group v-model="item.style.borderShow">
<el-radio :value="false">隐藏</el-radio>
<el-radio :value="true">显示</el-radio>
</el-radio-group>
</div>
<template v-if="item.style.borderShow">
<div class="video-config-row">
<div class="video-config-label">边框样式</div>
<el-radio-group v-model="item.style.borderStyle">
<el-radio value="solid">实线</el-radio>
<el-radio value="dashed">虚线</el-radio>
<el-radio value="dotted">点状</el-radio>
</el-radio-group>
</div>
<div class="video-config-row">
<div class="video-config-label">边框粗细</div>
<el-slider
v-model="item.style.borderWidth"
:min="1"
:max="10"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">边框颜色</div>
<div class="video-color-pair">
<el-color-picker v-model="item.style.borderColor" />
<el-button link type="primary" @click="item.style.borderColor = '#e5e5e5'">
重置
</el-button>
</div>
</div>
</template>
</div>
<div class="video-config-block">
<div class="video-config-block-title">阴影设置</div>
<div class="video-config-row">
<el-radio-group v-model="item.style.shadowShow">
<el-radio :value="false">隐藏</el-radio>
<el-radio :value="true">显示</el-radio>
</el-radio-group>
</div>
<template v-if="item.style.shadowShow">
<div class="video-config-row">
<div class="video-config-label">阴影颜色</div>
<div class="video-color-pair">
<el-color-picker v-model="item.style.shadowColor" show-alpha />
<el-button
link
type="primary"
@click="item.style.shadowColor = 'rgba(0,0,0,0.1)'"
>
重置
</el-button>
</div>
</div>
<div class="video-config-row">
<div class="video-config-label">X轴偏移</div>
<el-slider
v-model="item.style.shadowX"
:min="-20"
:max="20"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">Y轴偏移</div>
<el-slider
v-model="item.style.shadowY"
:min="-20"
:max="20"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">模糊半径</div>
<el-slider
v-model="item.style.shadowBlur"
:min="0"
:max="40"
show-input
:show-input-controls="false"
/>
</div>
<div class="video-config-row">
<div class="video-config-label">扩展半径</div>
<el-slider
v-model="item.style.shadowSpread"
:min="0"
:max="40"
show-input
:show-input-controls="false"
/>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { ensureVideoItem } from "./video-style";
defineOptions({ name: "VideoConfig" });
const props = defineProps({
item: {
type: Object,
required: true,
},
});
defineEmits(["select-file"]);
const activeTab = ref("content");
ensureVideoItem(props.item);
watch(
() => props.item,
(val) => {
if (val) ensureVideoItem(val);
},
{ immediate: true, deep: true }
);
function resetBgColors() {
props.item.style.bgColorStart = "#F5F5F5";
props.item.style.bgColorEnd = "#F5F5F5";
}
</script>
<style scoped lang="scss">
.video-config-tabs {
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
}
.video-config-section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
}
.video-config-block {
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.video-config-block-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.video-config-row {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
gap: 12px;
}
.video-config-label {
width: 72px;
flex-shrink: 0;
line-height: 32px;
color: #606266;
font-size: 13px;
}
.video-upload-box {
width: 88px;
height: 88px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
background: #fafafa;
&:hover {
border-color: #409eff;
}
}
.video-upload-box--small {
width: 72px;
height: 72px;
}
.video-upload-preview {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-upload-plus {
color: #c0c4cc;
}
.video-ratio-group {
flex: 1;
padding-top: 6px;
}
.video-color-pair {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
:deep(.el-slider) {
flex: 1;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,102 @@
export const DEFAULT_VIDEO_STYLE = {
bgType: "color",
bgColorStart: "#F5F5F5",
bgColorEnd: "#F5F5F5",
bgGradientDir: "horizontal",
bgRadius: 0,
bottomBgColor: "#F5F5F5",
bgImage: "",
margin: 0,
padding: 0,
borderShow: false,
borderStyle: "solid",
borderWidth: 1,
borderColor: "#e5e5e5",
shadowShow: false,
shadowColor: "rgba(0,0,0,0.1)",
shadowX: 0,
shadowY: 0,
shadowBlur: 10,
shadowSpread: 0,
};
export const ASPECT_RATIO_MAP = {
"16:9": 56.25,
"4:3": 75,
"1:1": 100,
};
export function ensureVideoItem(item) {
if (!item.aspectRatio) item.aspectRatio = "16:9";
if (!item.style) {
item.style = { ...DEFAULT_VIDEO_STYLE };
return;
}
Object.keys(DEFAULT_VIDEO_STYLE).forEach((key) => {
if (item.style[key] === undefined) {
item.style[key] = DEFAULT_VIDEO_STYLE[key];
}
});
}
export function getVideoGradient(style) {
const start = style.bgColorStart || "#F5F5F5";
const end = style.bgColorEnd || start;
const colors = `${start}, ${end}`;
const dirMap = {
horizontal: `linear-gradient(to right, ${colors})`,
vertical: `linear-gradient(to bottom, ${colors})`,
skewLeft: `linear-gradient(135deg, ${colors})`,
skewRight: `linear-gradient(45deg, ${colors})`,
};
return dirMap[style.bgGradientDir] || dirMap.horizontal;
}
export function getVideoWrapperStyle(item) {
ensureVideoItem(item);
const style = item.style;
const wrapper = {
margin: `${style.margin || 0}px`,
padding: `${style.padding || 0}px`,
background: style.bottomBgColor || "#F5F5F5",
boxSizing: "border-box",
};
return wrapper;
}
export function getVideoBoxStyle(item) {
ensureVideoItem(item);
const style = item.style;
const box = {
borderRadius: `${style.bgRadius || 0}px`,
overflow: "hidden",
position: "relative",
width: "100%",
};
if (style.bgType === "image" && style.bgImage) {
box.backgroundImage = `url(${style.bgImage})`;
box.backgroundSize = "cover";
box.backgroundPosition = "center";
} else {
box.background = getVideoGradient(style);
}
if (style.borderShow) {
box.border = `${style.borderWidth || 1}px ${style.borderStyle || "solid"} ${
style.borderColor || "#e5e5e5"
}`;
}
if (style.shadowShow) {
box.boxShadow = `${style.shadowX || 0}px ${style.shadowY || 0}px ${
style.shadowBlur || 10
}px ${style.shadowSpread || 0}px ${style.shadowColor || "rgba(0,0,0,0.1)"}`;
}
return box;
}
export function getAspectPadding(ratio) {
return ASPECT_RATIO_MAP[ratio] || ASPECT_RATIO_MAP["16:9"];
}