refactor(page-decoration): 重构装修页面组件与交互方式

- 统一 liliDialog ref 命名为 liliDialogRef,避免与组件 import 冲突
- 商品选择弹窗改为 el-table 表格形式展示
- 链接选择器从 el-dialog 改为 el-drawer 抽屉形式
- 为所有 img 标签添加 alt 属性提升可访问性
- 新增拼团、直播、秒杀、砍价、积分商城、优惠券等营销组件默认配置
- 删除商品分类组件,新增商品列表组件配置
- i18n 配置从 legacy 模式切换为 composition 模式
- 注册 PanelSearch、PanelNotice、PanelVideo 等新面板组件
- 优化画布预览区域样式并添加页面设置入口
This commit is contained in:
pikachu1995@126.com
2026-10-20 16:13:28 +08:00
parent d87e0c5f6b
commit d29ad9c99b
78 changed files with 8441 additions and 1350 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,7 +2,7 @@
<div class="hotzone-box">
<div class="hotzone-item">
<div ref="content" class="hz-m-wrap">
<img class="hz-u-img" :src="image" />
<img alt="热区背景图" class="hz-u-img" :src="image" />
<ul class="hz-m-area" v-add-item>
<zone
class="hz-m-item"

View File

@@ -36,10 +36,10 @@
@mouseup.stop
@mousemove.stop
>
<img width="17" height="17" src="../assets/styles/icons8-edit-64.png"></img>
<img alt="编辑" width="17" height="17" src="../assets/styles/icons8-edit-64.png"></img>
</li>
<li class="hz-flex-img">
<img class="hz-u-img" :src="zoneForm.img" />
<img alt="热区图片" class="hz-u-img" :src="zoneForm.img" />
</li>
<li class="hz-u-square hz-u-square-tl" data-pointer="dealTL"></li>
<li class="hz-u-square hz-u-square-tc" data-pointer="dealTC"></li>
@@ -79,7 +79,7 @@
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog width="1200px" v-model="picModelFlag" append-to-body destroy-on-close>
<ossManage
@@ -124,7 +124,7 @@ const zoneForm = reactive({
// 未在原 data 中声明、直接挂载在 this 上的遗留字段(仅赋值,未被读取)
let selectedNav;
const liliDialog = ref(null);
const liliDialogRef = ref(null);
// 模板 ref 与 import 组件同名会互相覆盖,故改名 ossManageRef
const ossManageRef = ref(null);
@@ -179,7 +179,7 @@ function callbackSelected(item) {
// 调起选择链接弹窗
function handleSelectLink(item, index) {
if (item) selectedNav = item;
liliDialog.value.open("link");
liliDialogRef.value.open("link");
}
// 已选链接
function selectedLink(val) {

View File

@@ -28,49 +28,60 @@
</div>
</div>
<div>
<div class="wap-content-list">
<div
class="wap-content-item"
:class="{ active: item.selected }"
@click="checkedGoods(item, index)"
v-for="(item, index) in goodsData"
:key="index"
>
<div>
<img :src="item.thumbnail" alt="" />
</div>
<div class="wap-content-desc">
<div class="wap-content-desc-title">{{ item.goodsName }}</div>
<div class="wap-sku">
{{ item.goodsUnit }}
<el-tag
v-if="item.goodsType"
style="margin-left: 6px"
size="small"
:type="goodsTypeTagType(item.goodsType)"
>
{{ goodsTypeLabel(item.goodsType) }}
</el-tag>
<el-tag
style="margin-left: 6px"
:type="item.salesModel === 'RETAIL' ? 'info' : 'primary'"
>
{{ item.salesModel === "RETAIL" ? "零售型" : "批发型" }}
</el-tag>
</div>
<div class="wap-content-desc-bottom">
<div>¥{{ $filters.unitPrice(item.price) }}</div>
<div v-if="item.goodsType === 'E_COUPON'" class="pool-stock-hint">
库存 {{ item.quantity != null ? item.quantity : "—" }}(卡池)
</div>
</div>
</div>
</div>
<div v-if="loading" v-loading="loading" class="loading-mask" />
<div v-if="empty" class="empty">暂无商品信息</div>
</div>
<el-table
ref="tableRef"
v-loading="loading"
class="goods-select-table"
:data="goodsData"
row-key="id"
height="340"
empty-text="暂无商品信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column label="商品图片" width="90" align="center">
<template #default="{ row }">
<img class="goods-thumb" :src="row.thumbnail" alt="" />
</template>
</el-table-column>
<el-table-column
prop="goodsName"
label="商品名称"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="商品规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.simpleSpecs || "默认规格" }}
</template>
</el-table-column>
<el-table-column label="价格" width="110">
<template #default="{ row }">
<span class="goods-price">¥{{ $filters.unitPrice(row.price) }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="90">
<template #default="{ row }">
{{ row.quantity != null ? row.quantity : "—" }}
</template>
</el-table-column>
<el-table-column label="销量" width="90">
<template #default="{ row }">
{{ row.buyCount != null ? row.buyCount : 0 }}
</template>
</el-table-column>
<el-table-column label="商品类型" width="110">
<template #default="{ row }">
{{ goodsTypeLabel(row.goodsType) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="goodsParams.pageNumber"
class="pageration"
@@ -85,9 +96,9 @@
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { nextTick, onMounted, reactive, ref, watch } from "vue";
import * as API_Goods from "@/api/goods";
import { goodsTypeLabel, goodsTypeTagType } from "@/constants/goodsType";
import { goodsTypeLabel } from "@/constants/goodsType";
const props = defineProps({
selectedWay: {
@@ -114,8 +125,8 @@ const goodsParams = reactive({
});
const category = ref([]);
const goodsData = ref([]);
const empty = ref(false);
const loading = ref(false);
const tableRef = ref(null);
watch(category, (val) => {
goodsParams.categoryPath = val && val.length ? val[2] : "";
@@ -124,6 +135,7 @@ watch(
() => props.selectedWay,
(val) => {
selectedList.value = Array.isArray(val) ? val.slice() : [];
nextTick(syncTableSelection);
},
{ deep: true, immediate: true }
);
@@ -162,28 +174,46 @@ function getQueryGoodsList() {
}
function initGoods(res) {
const records = res?.result?.records || [];
if (records.length) {
records.forEach((item) => {
item.selected = false;
item.___type = "goods";
selectedList.value.forEach((e) => {
if (e.id && e.id === item.id) {
item.selected = true;
}
});
});
total.value = res.result.total || 0;
goodsData.value = records;
empty.value = false;
} else {
goodsData.value = [];
empty.value = true;
records.forEach((item) => {
item.___type = "goods";
});
total.value = res?.result?.total || 0;
goodsData.value = records;
nextTick(syncTableSelection);
}
function syncTableSelection() {
const table = tableRef.value;
if (!table) return;
if (!selectedList.value.length) {
table.clearSelection();
return;
}
goodsData.value.forEach((item) => {
const selected = selectedList.value.some((e) => e.id && e.id === item.id);
table.toggleRowSelection(item, selected);
});
}
function emitSelected(list) {
selectedList.value = list;
emit("selected", selectedList.value);
}
function onSelectionChange(rows) {
// 选择链接时为单选:保留最后勾选的一项,多选框交互不变
if (type.value !== "multiple") {
if (!rows.length) {
emitSelected([]);
return;
}
const row = rows[rows.length - 1];
if (rows.length > 1) {
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
}
emitSelected([row]);
return;
}
emitSelected(rows);
}
function init() {
API_Goods.getGoodsSkuData(goodsParams).then((res) => {
initGoods(res);
@@ -221,24 +251,6 @@ function deepGroup(val) {
});
});
}
function checkedGoods(val) {
if (type.value !== "multiple") {
goodsData.value.forEach((item) => {
item.selected = false;
});
val.selected = true;
emitSelected([val]);
return;
}
if (!val.selected) {
val.selected = true;
emitSelected([...selectedList.value, val]);
} else {
val.selected = false;
emitSelected(selectedList.value.filter((item) => item.id !== val.id));
}
}
// 父组件 lili-dialog/index.vue 通过 $refs.goodsDialog.type = "single" 切换单选;
// expose 的 ref 外部读写等价于原 data 字段
@@ -248,31 +260,19 @@ defineExpose({ type });
@import "./style.scss";
.wap-content {
width: 100%;
}
.empty {
text-align: center;
padding: 8px 0;
width: 100%;
}
.wap-content {
flex: 1;
padding: 0;
}
.wap-content-list {
display: flex;
position: relative;
flex-wrap: wrap;
height: 340px;
.goods-thumb {
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: cover;
}
.wap-content-item {
width: 210px;
margin: 10px 7px;
padding: 6px 0;
}
.active {
background: url("../../assets/selected.png") no-repeat;
background-position: right;
background-size: 10%;
.goods-price {
color: $theme_color;
}
.pageration {
display: flex;
@@ -283,10 +283,3 @@ defineExpose({ type });
box-sizing: border-box;
}
</style>
<style lang="scss">
.pool-stock-hint {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<el-dialog
v-if="goodsFlag"
v-model="flag"
width="1160px"
top="120px"
@@ -9,32 +10,49 @@
destroy-on-close
@close="clickClose"
>
<template v-if="flag">
<goodsDialog
@selected="
(val) => {
goodsData = val;
}
"
v-if="goodsFlag"
ref="goodsDialogRef"
:selectedWay="goodsData"
/>
<linkDialog
@selectedLink="
(val) => {
linkData = val;
}
"
v-else
class="linkDialog"
/>
</template>
<goodsDialog
v-if="flag"
ref="goodsDialogRef"
:selectedWay="goodsData"
@selected="
(val) => {
goodsData = val;
}
"
/>
<template #footer>
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
</template>
</el-dialog>
<el-drawer
v-else
v-model="flag"
class="link-selector-drawer"
direction="rtl"
size="1160px"
:with-header="false"
:z-index="10000"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@close="clickClose"
>
<linkDialog
v-if="flag"
class="linkDialog"
@selectedLink="
(val) => {
linkData = val;
}
"
/>
<template #footer>
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref } from "vue";
@@ -120,3 +138,22 @@ defineExpose({
overflow: hidden;
}
</style>
<style lang="scss">
// 抽屉 append-to-body 后不在当前组件 DOM 内,不能使用 scoped 样式
.link-selector-drawer {
max-width: 96vw;
.el-drawer__body {
overflow: hidden;
}
.goods-select-table,
.shops-select-table,
.articles-select-table,
.specials-select-table {
height: calc(100vh - 240px) !important;
}
}
// 商品分类下拉挂载到 body,需要高于抽屉的 10000 层级
.goods-dialog-cascader-popper {
z-index: 10001 !important;
}
</style>

View File

@@ -34,8 +34,8 @@ const route = useRoute();
const changed = ref(""); // 变更模板
const selected = ref(0); // 已选数据
const selectedLink = ref(""); //选中的链接
// wap 为共享的模块级数组,保持原 data 引用同一数组的行为
const wap = ref(wapList); // tab标签
// 选择链接不再提供“活动”tab,wap.js 中的配置保留用于已有链接的名称展示
const wap = ref(wapList.filter((item) => item.name !== "marketing")); // tab标签
const activeTab = ref(wapList[0]?.title || "");
// v-for 内模板 ref 收集为数组;原名 lili-component 含连字符,改名并同步 script
const liliComponentRef = ref([]);
@@ -75,11 +75,6 @@ onMounted(() => {
});
}
} else {
wap.value.push({
title: "活动",
url: "3",
name: "marketing",
});
let obj = {};
wap.value = wap.value.reduce((cur, next) => {
//对象去重

View File

@@ -1,36 +1,45 @@
<template>
<div>
<el-row :gutter="30">
<template v-for="(item, index) in linkList" :key="index">
<el-col
v-if="showLinkItem(item)"
:span="4"
>
<div
class="card"
:class="{ active: selectedIndex == index }"
@click="handleLink(item, index)"
>
<el-icon :size="24">
<component :is="item.icon" />
</el-icon>
<p>{{ item.title }}</p>
</div>
</el-col>
</template>
<el-col v-if="linkVisible" :span="4">
<div
class="card"
:class="{ active: selectedIndex == linkList.length }"
@click="handleLink(linkItem, linkList.length)"
>
<el-icon :size="24">
<component :is="linkItem.icon" />
<div class="link-list">
<template v-for="(item, index) in linkList" :key="index">
<div
v-if="showLinkItem(item)"
class="card"
:class="{ active: selectedIndex == index }"
@click="handleLink(item, index)"
>
<span class="link-icon">
<el-icon :size="18">
<component :is="item.icon" />
</el-icon>
<p>{{ linkItem.title }}</p>
</div>
</el-col>
</el-row>
</span>
<p>{{ item.title }}</p>
<el-icon v-if="selectedIndex == index" class="selected-icon" :size="18">
<Check />
</el-icon>
<el-icon v-else class="arrow-icon" :size="16">
<ArrowRight />
</el-icon>
</div>
</template>
<div
v-if="linkVisible"
class="card"
:class="{ active: selectedIndex == linkList.length }"
@click="handleLink(linkItem, linkList.length)"
>
<span class="link-icon">
<el-icon :size="18">
<component :is="linkItem.icon" />
</el-icon>
</span>
<p>{{ linkItem.title }}</p>
<el-icon v-if="selectedIndex == linkList.length" class="selected-icon" :size="18">
<Check />
</el-icon>
<el-icon v-else class="arrow-icon" :size="16">
<ArrowRight />
</el-icon>
</div>
</div>
</template>
<script setup>
@@ -49,6 +58,8 @@ import {
Share,
ShoppingBag,
Link,
ArrowRight,
Check,
} from "@element-plus/icons-vue";
const emit = defineEmits(["selected"]);
@@ -109,25 +120,74 @@ function handleLink(val, index) {
</script>
<style lang="scss" scoped>
@import "../style.scss";
.link-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
max-height: calc(100vh - 170px);
padding: 4px;
overflow-y: auto;
}
.card {
position: relative;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 20px 0;
margin: 10px 0;
text-align: center;
transition: 0.35s;
flex-direction: column;
min-height: 104px;
padding: 16px 10px 14px;
margin: 0;
cursor: pointer;
:deep(p) {
margin: 10px 0;
text-align: center;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
transition: all 0.2s ease;
p {
margin: 10px 0 0;
color: #303133;
font-size: 14px;
font-weight: 500;
text-align: center;
}
border: 1px solid #ededed;
}
.card:hover {
background: #ededed;
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.12);
transform: translateY(-1px);
}
.active {
background: #ededed;
.link-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
color: #409eff;
background: #ecf5ff;
border-radius: 10px;
}
.arrow-icon {
display: none;
}
.selected-icon {
position: absolute;
top: 8px;
right: 8px;
color: #409eff;
}
.active,
.active:hover {
color: #409eff;
background: #ecf5ff;
border-color: #409eff;
box-shadow: none;
p {
color: #409eff;
}
.link-icon {
color: #fff;
background: #409eff;
}
}
</style>

View File

@@ -1,72 +1,211 @@
<template>
<div class="wrapper">
<!-- TODO 目前数据少暂且不用 -->
<!-- <div class="list">
<div class="list-item active">
文章页
</div>
</div> -->
<div class="content">
<Article @callbacked="callbackArticle" :selected="true" />
</div>
<div class="query-wrapper">
<div class="query-item">
<div>文章标题</div>
<el-input
v-model="params.title"
placeholder="请输入文章标题"
clearable
style="width: 180px"
@clear="resetSearch"
@keyup.enter="resetSearch"
/>
</div>
<div class="query-item">
<el-select
v-model="params.categoryId"
placeholder="全部分类"
clearable
filterable
style="width: 220px"
@change="resetSearch"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</div>
<div class="query-item">
<el-button type="primary" @click="resetSearch">搜索</el-button>
</div>
</div>
<div>
<el-table
ref="tableRef"
v-loading="loading"
class="articles-select-table"
:data="data"
row-key="id"
height="340"
empty-text="暂无文章信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column
prop="title"
label="文章标题"
min-width="240"
show-overflow-tooltip
/>
<el-table-column
prop="articleCategoryName"
label="分类名称"
width="140"
show-overflow-tooltip
/>
<el-table-column label="是否显示" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.openStatus ? 'success' : 'info'" size="small">
{{ row.openStatus ? "展示" : "隐藏" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column
prop="id"
label="文章ID"
width="180"
show-overflow-tooltip
/>
</el-table>
<el-pagination
v-model:current-page="params.pageNumber"
class="pageration"
size="small"
layout="total, prev, pager, next, jumper"
:total="total"
:page-size="params.pageSize"
@current-change="changePage"
/>
</div>
</div>
</div>
</template>
<script setup>
import Article from "@/views/page/article-manage/articleList.vue";
import { nextTick, onMounted, reactive, ref } from "vue";
import { getArticle, getArticleCategory } from "@/api/pages";
const emit = defineEmits(["selected"]);
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
function callbackArticle(val) {
val.___type = "pages";
const loading = ref(false);
const total = ref(0);
const params = reactive({
pageNumber: 1,
pageSize: 20,
sort: "createTime",
order: "desc",
categoryId: "",
title: "",
});
const data = ref([]);
const categories = ref([]);
const tableRef = ref(null);
const selectedArticle = ref(null);
val.___path = "/pages/passport/article";
onMounted(() => {
loadCategories();
getDataList();
});
emit("selected", [val]);
function flattenCategories(tree, level = 0) {
const result = [];
(tree || []).forEach((node) => {
result.push({
id: node.id,
name: `${" ".repeat(level)}${node.articleCategoryName}`,
});
if (node.children && node.children.length) {
result.push(...flattenCategories(node.children, level + 1));
}
});
return result;
}
function loadCategories() {
getArticleCategory().then((res) => {
if (res.success) {
categories.value = flattenCategories(res.result);
}
});
}
function resetSearch() {
params.pageNumber = 1;
getDataList();
}
function changePage(v) {
params.pageNumber = v;
getDataList();
}
function getDataList() {
loading.value = true;
const query = { ...params };
if (!query.categoryId) delete query.categoryId;
if (!query.title) delete query.title;
getArticle(query)
.then((res) => {
if (res.success) {
total.value = res.result.total;
data.value = res.result.records || [];
nextTick(syncTableSelection);
}
})
.finally(() => {
loading.value = false;
});
}
function syncTableSelection() {
const table = tableRef.value;
if (!table || !selectedArticle.value) return;
data.value.forEach((row) => {
table.toggleRowSelection(row, row.id === selectedArticle.value.id);
});
}
function onSelectionChange(rows) {
// 选择链接只能保留一篇文章,勾选新文章时自动取消上一篇
if (!rows.length) {
selectedArticle.value = null;
emit("selected", []);
return;
}
const row = rows[rows.length - 1];
if (rows.length > 1) {
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
}
selectedArticle.value = row;
emit("selected", [
{ ...row, ___type: "pages", ___path: "/pages/passport/article" },
]);
}
</script>
<style lang="scss" scoped>
:deep(.el-card__body) {
height: 414px;
overflow: auto;
}
.list {
margin: 0 1.5%;
height: 400px;
overflow: auto;
> .list-item {
padding: 10px;
transition: 0.35s;
cursor: pointer;
}
.list-item:hover {
background: #ededed;
}
}
.list {
flex: 2;
width: auto;
}
@import "../style.scss";
.content {
overflow: hidden;
flex: 8;
height: 431px;
flex: 1;
}
.active {
background: #ededed;
}
.wrapper {
height: 416px;
overflow: hidden;
}
:deep(.el-table) {
height: 300px !important;
overflow: auto;
}
:deep(.el-card__body) {
padding: 0;
height: auto;
.pageration {
margin-top: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -18,31 +18,55 @@
</div>
</div>
<div>
<div v-loading="loading" class="wap-content-list">
<div
v-for="(item, index) in shopsData"
:key="index"
class="wap-content-item"
:class="{ active: selected == index }"
@click="clickShop(item, index)"
>
<div>
<img class="shop-logo" :src="item.storeLogo" alt="" />
</div>
<div class="wap-content-desc">
<div class="wap-content-desc-title">{{ item.storeName }}</div>
<div class="self-operated" :class="{ theme_color: item.selfOperated }">
{{ item.selfOperated ? "自营" : "非自营" }}
</div>
<div
class="wap-sku"
:class="{ theme_color: item.storeDisable === 'OPEN' }"
>
{{ item.storeDisable === "OPEN" ? "开启中" : "未开启" }}
</div>
</div>
</div>
</div>
<el-table
ref="tableRef"
v-loading="loading"
class="shops-select-table"
:data="shopsData"
row-key="id"
height="340"
empty-text="暂无店铺信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column label="店铺图片" width="100" align="center">
<template #default="{ row }">
<img class="shop-logo" :src="row.storeLogo" alt="" />
</template>
</el-table-column>
<el-table-column
prop="storeName"
label="店铺名称"
min-width="240"
show-overflow-tooltip
/>
<el-table-column
prop="createTime"
label="创建时间"
width="180"
show-overflow-tooltip
/>
<el-table-column label="店铺类型" width="120">
<template #default="{ row }">
<span :class="{ theme_color: row.selfOperated }">
{{ row.selfOperated ? "自营" : "非自营" }}
</span>
</template>
</el-table-column>
<el-table-column label="店铺状态" width="120">
<template #default="{ row }">
<span :class="{ theme_color: row.storeDisable === 'OPEN' }">
{{ row.storeDisable === "OPEN" ? "开启中" : "未开启" }}
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pageration"
size="small"
@@ -57,7 +81,7 @@
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { nextTick, reactive, ref } from "vue";
import { getShopListData } from "@/api/shops.js";
const emit = defineEmits(["selected"]);
@@ -71,7 +95,8 @@ const params = reactive({
storeName: "",
});
const shopsData = ref([]);
const selected = ref(9999999999);
const tableRef = ref(null);
const selectedShop = ref(null);
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
@@ -92,19 +117,41 @@ function changePageSize(v) {
function init() {
loading.value = true;
getShopListData(params).then((res) => {
if (res.success) {
total.value = res.result.total;
shopsData.value = res.result.records;
}
loading.value = false;
getShopListData(params)
.then((res) => {
if (res.success) {
total.value = res.result.total;
shopsData.value = res.result.records;
nextTick(syncTableSelection);
}
})
.finally(() => {
loading.value = false;
});
}
function syncTableSelection() {
const table = tableRef.value;
if (!table || !selectedShop.value) return;
shopsData.value.forEach((row) => {
table.toggleRowSelection(row, row.id === selectedShop.value.id);
});
}
function clickShop(val, i) {
selected.value = i;
val = { ...val, ___type: "shops" };
emit("selected", [val]);
function onSelectionChange(rows) {
// 选择链接只能保留一个店铺,勾选新店铺时自动取消上一个
if (!rows.length) {
selectedShop.value = null;
emit("selected", []);
return;
}
const row = rows[rows.length - 1];
if (rows.length > 1) {
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
}
selectedShop.value = row;
emit("selected", [{ ...row, ___type: "shops" }]);
}
</script>
<style lang="scss" scoped>
@@ -112,23 +159,13 @@ function clickShop(val, i) {
.shop {
display: flex;
}
.self-operated {
font-size: 12px;
color: #999;
}
.wap-content-list {
display: flex;
flex-wrap: wrap;
height: 340px;
min-height: 120px;
}
.shop-logo {
object-fit: cover;
}
.active {
background: url("../../../assets/selected.png") no-repeat;
background-position: right;
background-size: 10%;
display: block;
width: 48px;
height: 48px;
margin: 0 auto;
border-radius: 4px;
object-fit: contain;
}
.pageration {
margin-top: 12px;

View File

@@ -1,50 +1,67 @@
<template>
<div class="wrapper">
<div class="content">
<div>
<div class="tables">
<el-table v-loading="loading" border height="350" :data="data" style="width: 100%">
<el-table-column prop="name" label="专题名称" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row, $index }">
<el-button
v-if="row"
:type="index === $index ? 'primary' : 'default'"
size="small"
@click="selectRow(row, $index)"
>
{{ index === $index ? "已选" : "选择" }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-table
ref="tableRef"
v-loading="loading"
class="specials-select-table"
:data="data"
row-key="id"
height="340"
empty-text="暂无专题信息"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
label="选择"
width="60"
align="center"
reserve-selection
/>
<el-table-column
prop="id"
label="专题ID"
width="240"
show-overflow-tooltip
/>
<el-table-column
prop="name"
label="专题名称"
min-width="260"
show-overflow-tooltip
/>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.pageShow === 'OPEN' ? 'success' : 'info'" size="small">
{{ row.pageShow === "OPEN" ? "开启" : "关闭" }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
class="mt_10"
:total="Number(total)"
layout="prev, pager, next, jumper"
size="small"
@current-change="changePageNum"
@size-change="changePageSize"
/>
</div>
</div>
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
class="pageration"
:total="Number(total)"
layout="total, prev, pager, next, jumper"
size="small"
@current-change="changePageNum"
@size-change="changePageSize"
/>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { nextTick, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { getHomeList } from "@/api/other.js";
const emit = defineEmits(["selected"]);
const route = useRoute();
const loading = ref(true);
const index = ref(999);
const data = ref([]);
const params = reactive({
sort: "createTime",
@@ -55,12 +72,9 @@ const params = reactive({
pageType: "SPECIAL",
});
const total = ref(0);
const tableRef = ref(null);
const selectedSpecial = ref(null);
function selectRow(row, idx) {
index.value = idx;
const payload = { ...row, pageType: "special", ___type: "special" };
emit("selected", [payload]);
}
function changePageNum(val) {
params.pageNumber = val;
init();
@@ -71,43 +85,54 @@ function changePageSize(val) {
init();
}
async function init() {
loading.value = true;
params.pageClientType = route.name === "renovation" ? "PC" : "H5";
const res = await getHomeList(params);
if (res.success) {
loading.value = false;
data.value = res.result.records;
data.value = res.result.records || [];
total.value = res.result.total;
} else {
loading.value = false;
nextTick(syncTableSelection);
}
loading.value = false;
}
function syncTableSelection() {
const table = tableRef.value;
if (!table || !selectedSpecial.value) return;
data.value.forEach((row) => {
table.toggleRowSelection(row, row.id === selectedSpecial.value.id);
});
}
function onSelectionChange(rows) {
// 选择链接只能保留一个专题,勾选新专题时自动取消上一个
if (!rows.length) {
selectedSpecial.value = null;
emit("selected", []);
return;
}
const row = rows[rows.length - 1];
if (rows.length > 1) {
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
}
selectedSpecial.value = row;
emit("selected", [{ ...row, pageType: "special", ___type: "special" }]);
}
onMounted(() => {
init();
});
</script>
<style lang="scss" scoped>
img {
max-width: 100% !important;
}
.search {
width: 300px;
}
.page {
margin-top: 2vh;
text-align: right;
}
.tables {
height: 400px;
margin-top: 20px;
overflow: auto;
width: 100%;
}
.content {
overflow: hidden;
flex: 4;
flex: 1;
}
.wrapper {
overflow: hidden;
.pageration {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -12,7 +12,8 @@ const messages = {
};
const i18n = createI18n({
legacy: true,
// legacy:true 会禁用组合式 useI18n;关闭后配合 globalInjection 旧的 $t 写法依然可用
legacy: false,
globalInjection: true,
locale: lang,
fallbackLocale: "zh-CN",

View File

@@ -25,9 +25,6 @@
<el-dropdown-item v-if="res.store" command="shopAuth">
<el-badge :value="res.store">待处理店铺入驻审核</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.waitPayBill" command="accountStatementBill">
<el-badge :value="res.waitPayBill">待与商家对账</el-badge>
</el-dropdown-item>
<el-dropdown-item v-if="res.appointmentWorkOrder" command="manager-appointment-work-orders">
<el-badge :value="res.appointmentWorkOrder">未完成预约工单</el-badge>
</el-dropdown-item>

View File

@@ -8,7 +8,7 @@
<el-carousel autoplay height="340px">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img :src="item.img" width="790" height="340" />
<img alt="轮播图" :src="item.img" width="790" height="340" />
</div>
</el-carousel-item>
</el-carousel>
@@ -117,7 +117,7 @@
</template>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@@ -153,7 +153,7 @@ const articleList = ref([
]);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
function cloneList(list) {
@@ -182,7 +182,7 @@ function handleAdd() {
}
function handleSelectLink(item) {
// 选择链接
liliDialog.value.open("link");
liliDialogRef.value.open("link");
selected.value = item;
}
function callbackSelected(item) {

View File

@@ -8,7 +8,7 @@
<el-carousel autoplay @change="autoChange">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img :src="item.img" width="1200" height="470" />
<img alt="轮播图" :src="item.img" width="1200" height="470" />
</div>
</el-carousel-item>
</el-carousel>
@@ -81,7 +81,7 @@
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" :max-select="1" ref="ossManageRef" />
@@ -107,7 +107,7 @@ const selected = ref(null); // 已选数据
const picModelFlag = ref(false); // 选择图片modal
const bgColor = ref("#fff"); // 轮播背景色
const liliDialog = ref(null);
const liliDialogRef = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
@@ -130,7 +130,7 @@ function handleAdd() {
}
// 打开选择链接modal
function handleSelectLink(item) {
liliDialog.value.open("link");
liliDialogRef.value.open("link");
selected.value = item;
}
function callbackSelected(item) {

View File

@@ -8,7 +8,7 @@
<el-carousel autoplay height="340px">
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden">
<img :src="item.img" width="590" height="470" />
<img alt="轮播图" :src="item.img" width="590" height="470" />
</div>
</el-carousel-item>
</el-carousel>
@@ -26,13 +26,13 @@
:key="index"
>
<div class="mb_10">
<img :src="item[0].img" width="190" height="150" />
<img alt="轮播图" :src="item[0].img" width="190" height="150" />
</div>
<div class="mb_10">
<img :src="item[1].img" width="190" height="150" />
<img alt="轮播图" :src="item[1].img" width="190" height="150" />
</div>
<div>
<img :src="item[2].img" width="190" height="150" />
<img alt="轮播图" :src="item[2].img" width="190" height="150" />
</div>
</el-carousel-item>
</el-carousel>
@@ -201,7 +201,7 @@
</div>
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink"></liliDialog>
<!-- 选择图片 -->
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" :max-select="1" ref="ossManageRef" />
@@ -231,7 +231,7 @@ const articleList = ref([
{ title: "促销计算规则" },
]);
const liliDialog = ref(null);
const liliDialogRef = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
@@ -258,7 +258,7 @@ function handleTabRemove(index) {
}
// 打开图片链接
function handleSelectLink(item) {
liliDialog.value.open("link");
liliDialogRef.value.open("link");
selected.value = item;
}
function callbackSelected(item) {

View File

@@ -100,7 +100,7 @@
class="draggable"
>
<div class="flex">
<img :src="item.img" class="column-img" />
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="column-goods-list">
<div><el-input v-model="item.title" /></div>
<div><el-input v-model="item.desc" /></div>
@@ -139,7 +139,7 @@
class="draggable"
>
<div class="flex">
<img :src="item.img" class="column-img" />
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="column-goods-list">
<el-button
size="small"
@@ -162,7 +162,7 @@
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -203,7 +203,7 @@ const selectedData = ref(""); //选中的数据
const goodsIndex = ref(0); // 商品索引
const defaultCallbackImageType = ref("");
const liliDialog = ref(null);
const liliDialogRef = ref(null);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const ossManageRef = ref(null);
@@ -223,14 +223,14 @@ function handleSelectImg(type, index) {
}
function handleSelectGoods(val, index) {
liliDialog.value.open("goods");
liliDialog.value.singleGoods();
liliDialogRef.value.open("goods");
liliDialogRef.value.singleGoods();
selectedData.value = val;
goodsIndex.value = index;
}
function handleSelectLink(val, index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
liliDialogRef.value.open("link");
selectedData.value = val;
goodsIndex.value = index;

View File

@@ -25,7 +25,7 @@
:key="index"
>
<div class="goods-img">
<img :src="item.img" />
<img alt="楼层图片" :src="item.img" />
</div>
<div>
<div class="goods-name wes-2">{{ item.title }}</div>
@@ -93,7 +93,7 @@
class="draggable"
>
<div class="flex">
<img :src="item.img" class="column-img" />
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="flex column-goods-config">
<div class="column-config">
<div>
@@ -120,7 +120,7 @@
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -146,7 +146,7 @@ const showModal = ref(false);
const draftOptions = ref(null);
const tabCurrentlyIndex = ref(0); // 选项卡索引
const liliDialog = ref(null);
const liliDialogRef = ref(null);
onMounted(() => {
tabIndex.value = props.data.options.labels[0].___index;
@@ -191,8 +191,8 @@ function handleClickTab(name) {
}
// 添加当前选项卡中的商品
function addCurrentGoods() {
liliDialog.value.clearGoodsSelected();
liliDialog.value.open("goods");
liliDialogRef.value.clearGoodsSelected();
liliDialogRef.value.open("goods");
}
// 编辑模块
function handleSelectModel() {
@@ -228,7 +228,7 @@ function selectedLink(val) {
item.type = val.___type === "other" && val.url === "" ? "link" : "other";
}
function handleSelectLink(index) {
liliDialog.value.open("link");
liliDialogRef.value.open("link");
current.value = index;
}
function handleCancelModal() {

View File

@@ -94,7 +94,7 @@
class="draggable"
>
<div class="flex column-item">
<img :src="item.img" class="column-img" />
<img alt="楼层图片" :src="item.img" class="column-img" />
<el-button
size="small"
class="ml_10"

View File

@@ -231,7 +231,7 @@
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -267,7 +267,7 @@ const editTarget = ref(null); // 确认后写回的原数据
const picModelFlag = ref(false); // 选择图片modal
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
function cloneItem(item) {
@@ -285,16 +285,16 @@ function handleSelectModel(item, type) {
}
function handleSelectLink() {
// 调起选择链接弹窗
liliDialog.value.open("link");
liliDialogRef.value.open("link");
}
function handleSelectGoods(item) {
if (item) {
editTarget.value = item;
selected.value = cloneItem(item);
}
liliDialog.value.open("goods", "single");
liliDialogRef.value.open("goods", "single");
setTimeout(() => {
liliDialog.value.goodsData = [selected.value];
liliDialogRef.value.goodsData = [selected.value];
}, 500);
}
// 选择链接回调

View File

@@ -91,7 +91,7 @@
</template>
</el-dialog>
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
</div>
@@ -117,7 +117,7 @@ const showContent = ref(true);
const draftNavList = ref([]);
const draftGoodsList = ref([]);
const liliDialog = ref(null);
const liliDialogRef = ref(null);
watch(() => props.data, function(val){
conData.value = val
@@ -146,9 +146,9 @@ function handleSelectGoods(item) {
showGoodsModal.value = true;
}
function handleOpenGoodsPicker() {
liliDialog.value.open('goods', 'single');
liliDialogRef.value.open('goods', 'single');
setTimeout(() => {
liliDialog.value.goodsData = [selected.value];
liliDialogRef.value.goodsData = [selected.value];
}, 500);
}
function selectedGoodsData(val){

View File

@@ -2,7 +2,7 @@
<div class="line flex flex-j-sb">
<div class="column" v-for="(item,index) in data.options.list" :key="index">
<div v-if="!item.img" class="placeholder">占位符</div>
<img v-else :src="item.img" class="three-column-img">
<img alt="楼层图片" v-else :src="item.img" class="three-column-img">
</div>
<div class="setup-box">
<div>
@@ -23,7 +23,7 @@
append-to-body destroy-on-close>
<div class="modal-tab-bar">
<div v-for="(item,index) in draftList" :key="index">
<img :src="item.img" class="three-column-img">
<img alt="楼层图片" :src="item.img" class="three-column-img">
<div>推荐尺寸:{{item.size}}</div>
<el-button
size="small"

View File

@@ -12,7 +12,7 @@
<div class="goods-desc">{{ item.desc }}</div>
</div>
<div class="goods-img">
<img :src="item.img" />
<img alt="楼层图片" :src="item.img" />
</div>
</div>
<div class="setup-box">
@@ -47,7 +47,7 @@
class="draggable"
>
<div class="flex">
<img :src="item.img" class="column-img" />
<img alt="楼层图片" :src="item.img" class="column-img" />
<div class="flex column-goods-config">
<div class="column-config">
<div>
@@ -73,7 +73,7 @@
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -97,7 +97,7 @@ const current = ref(0);
const showModal = ref(false);
const draftList = ref([]);
const liliDialog = ref(null);
const liliDialogRef = ref(null);
function cloneList(list) {
return JSON.parse(JSON.stringify(list || []));
@@ -110,7 +110,7 @@ function delGoods(val, i) {
// 添加当前选项卡中的商品
function addCurrentGoods() {
liliDialog.value.open("goods");
liliDialogRef.value.open("goods");
}
// 编辑模块
@@ -156,7 +156,7 @@ function selectedLink(val) {
function handleSelectLink(index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
liliDialogRef.value.open("link");
current.value = index;
}

View File

@@ -216,7 +216,7 @@
</el-dialog>
<!-- 选择商品。链接 -->
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -253,7 +253,7 @@ const editTarget = ref(null); // 确认后写回的原数据
const picModelFlag = ref(false); // 图片选择
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
function cloneItem(item) {
@@ -271,11 +271,11 @@ function handleSelectModel(item, type) {
}
function handleSelectLink(item, index) {
// 调起选择链接弹窗
liliDialog.value.open("link");
liliDialogRef.value.open("link");
}
function handleSelectGoods(item) {
// 调起选择商品
liliDialog.value.open("goods", "single");
liliDialogRef.value.open("goods", "single");
}
// 选择链接回调
function selectedLink(val) {

View File

@@ -79,7 +79,7 @@
</div>
</div>
<!-- <el-button type="primary" @click="addDecorate()" ghost>添加</el-button> -->
<liliDialog ref="liliDialog" @selectedLink="selectedLink" :types="linkType"></liliDialog>
<liliDialog ref="liliDialogRef" @selectedLink="selectedLink" :types="linkType"></liliDialog>
</div>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" ref="ossManageRef" />
@@ -112,7 +112,7 @@ const advertising = ref([
const selectedLinks = reactive({});
const linkType = ref(""); // 选择类型
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const ossManageRef = ref(null);
watch(
@@ -145,7 +145,7 @@ function openPage() {
}
// 点击链接
function clickLink(item) {
liliDialog.value.open('link')
liliDialogRef.value.open('link')
}
// 回调选择的链接
function selectedLink(val) {

View File

@@ -79,7 +79,7 @@
</div>
</div>
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
:types="linkType"
></liliDialog>
@@ -113,7 +113,7 @@ const advertising = ref([
const linkType = ref(""); // 选择类型
const selectedLinks = reactive({});
const liliDialog = ref(null);
const liliDialogRef = ref(null);
// files:v-for 文件输入框 ref,脚本内未直接读取(changeFile 通过 document.getElementById 取值)
const files = ref([]);
// ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
@@ -174,7 +174,7 @@ function handleClickFile(item, index) {
// 点击链接
function clickLink(item) {
liliDialog.value.open("link");
liliDialogRef.value.open("link");
}
//点击图片解析成base64

View File

@@ -16,7 +16,7 @@
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<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">
@@ -30,7 +30,7 @@
<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" size="small" maxlength="10" />
<el-input v-model="item.title" maxlength="10" />
</div>
<div class="btn-row">
<el-button plain type="primary" @click="pickImage(item)">选择照片</el-button>
@@ -62,32 +62,27 @@
<div class="panel-card">
<div class="section-title">颜色设置</div>
<color-field
v-model="res.options.bgColor1"
label="背景颜色"
:default-value="defaults.bgColor1"
<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.bgColor2"
label="渐变结束色"
:default-value="defaults.bgColor2"
/>
<div class="section-tip">起始色与结束色一致时为纯色背景</div>
</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="30" />
<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="50" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
</div>
<div class="panel-card">
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="50" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="50" />
<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>
@@ -110,6 +105,7 @@ import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/components/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 = {
@@ -127,7 +123,7 @@ const DEFAULTS = {
export default {
name: "PanelAdCube",
hideDecorateTitle: true,
components: { ossManage, ColorField, SliderField },
components: { ossManage, ColorField, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
@@ -200,11 +196,12 @@ export default {
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-ad-cube {
background: #f5f7fa;
margin: 0 -20px -120px;
padding: 0 12px 120px;
min-height: calc(100vh - 70px);
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
@@ -219,7 +216,7 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 14px;
font-size: 16px;
font-weight: bold;
color: #303133;
}
@@ -252,13 +249,13 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 12px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
@@ -271,7 +268,7 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-size {
@@ -307,7 +304,7 @@ export default {
align-items: center;
margin-bottom: 10px;
.field-label {
font-size: 13px;
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;

View File

@@ -1,73 +1,107 @@
<template>
<div class="panel-article">
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" style="width: 200px" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">文章分类</div>
<div class="ctrl-body">
<el-select
v-model="firstItem.categoryId"
placeholder="全部分类"
clearable
style="width: 200px"
<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"
>
<el-option
v-for="cat in categories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>
{{ tab.title }}
</div>
</div>
</div>
<slider-field
v-model="firstItem.count"
label="显示数量"
:min="1"
:max="20"
/>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<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>
<div class="ctrl-row">
<div class="ctrl-label">文章分类</div>
<div class="ctrl-body">
<el-select
v-model="firstItem.categoryId"
placeholder="全部分类"
clearable
>
<el-option
v-for="cat in categories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>
</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>
<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>
<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="50" />
<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 { getArticleCategory } from "@/api/pages";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
paddingTop: 10,
paddingBottom: 10,
contentGap: 10,
paddingX: 0,
pageGap: 10,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "文章列表", categoryId: "", count: 3 };
const DEFAULT_ITEM = { title: "文章列表", categoryId: "", count: 6 };
function flattenCategories(tree, level = 0) {
const result = [];
@@ -82,12 +116,21 @@ function flattenCategories(tree, level = 0) {
export default {
name: "PanelArticle",
components: { ColorField, SliderField },
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS, categories: [] };
return {
defaults: DEFAULTS,
categories: [],
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
};
},
computed: {
firstItem() {
@@ -110,4 +153,5 @@ export default {
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
</style>

View File

@@ -1,37 +1,40 @@
<template>
<div class="panel-blank">
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<slider-field
v-model="res.options.height"
label="空白高度"
:min="1"
:max="100"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<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 ColorField from "../controls/ColorField.vue";
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: 10,
@@ -39,7 +42,8 @@ const DEFAULTS = {
export default {
name: "PanelBlank",
components: { ColorField, SliderField },
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
@@ -51,3 +55,15 @@ export default {
},
};
</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

@@ -16,9 +16,8 @@
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="panel-card panel-card-fill">
<div class="section-title">图片设置</div>
<div class="section-tip">最多添加 10 张,建议添加比例一致的图片</div>
<div class="img-item" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>图片 {{ index + 1 }}</span>
@@ -36,16 +35,21 @@
<el-icon class="del" @click="removeItem(index)"><Delete /></el-icon>
</div>
</div>
<div class="img-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-placeholder" v-else>建议尺寸 750*350</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 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
@@ -62,20 +66,19 @@
<template v-else>
<div class="panel-card">
<div class="section-title">轮播风格</div>
<div class="setting-row">
<div class="setting-label">
风格
<span class="setting-value">{{ swiperStyleText }}</span>
</div>
<div class="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 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>
@@ -84,48 +87,48 @@
</div>
</div>
<div class="panel-card">
<div class="panel-card panel-card-fill">
<div class="section-title">指示器设置</div>
<div class="setting-row">
<div class="setting-label">
指示器样式
<span class="setting-value">{{ indicatorStyleText }}</span>
</div>
<div class="seg">
<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 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="setting-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="setting-label">
指示器位置
<span class="setting-value">{{ indicatorPositionText }}</span>
</div>
<div class="seg">
<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 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="setting-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="setting-label">色调</div>
<el-radio-group v-model="res.options.indicatorColorMode">
<el-radio value="theme">跟随主题风格</el-radio>
<el-radio value="custom">自定义</el-radio>
</el-radio-group>
<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="
@@ -143,31 +146,26 @@
<div class="panel-card">
<div class="section-title">颜色设置</div>
<color-field
v-model="res.options.bgColor1"
label="背景颜色"
:default-value="defaults.bgColor1"
<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.bgColor2"
label="渐变结束色"
:default-value="defaults.bgColor2"
/>
<div class="section-tip">起始色与结束色一致时为纯色背景</div>
</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="30" />
<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">
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="50" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="50" />
<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>
@@ -188,6 +186,7 @@
<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 "@/components/lili-dialog/wap.js";
@@ -211,7 +210,7 @@ const DEFAULTS = {
export default {
name: "PanelCarousel",
hideDecorateTitle: true,
components: { ColorField, SliderField, ossManage, Top, Bottom, Delete },
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
@@ -241,26 +240,6 @@ export default {
currentItem: null,
};
},
computed: {
swiperStyleText() {
const hit = this.swiperStyleOptions.find(
(o) => o.value === this.res.options.swiperStyle
);
return hit ? hit.title : "";
},
indicatorStyleText() {
const hit = this.indicatorStyleOptions.find(
(o) => o.value === this.res.options.indicatorStyle
);
return hit ? hit.title : "";
},
indicatorPositionText() {
const hit = this.indicatorPositionOptions.find(
(o) => o.value === this.res.options.indicatorPosition
);
return hit ? hit.title : "";
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list)) {
@@ -329,11 +308,13 @@ export default {
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-carousel {
background: #f5f7fa;
margin: 0 -20px -120px;
padding: 0 12px 120px;
min-height: calc(100vh - 70px);
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
@@ -348,7 +329,7 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 14px;
font-size: 16px;
font-weight: bold;
color: #303133;
}
@@ -381,13 +362,13 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 12px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
@@ -400,7 +381,7 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-item-ops {
@@ -424,27 +405,43 @@ export default {
}
}
}
.img-preview {
.img-item-body {
display: flex;
gap: 10px;
}
.img-thumb {
width: 132px;
height: 62px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 6px;
margin-bottom: 10px;
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-placeholder {
height: 80px;
.img-item-fields {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
color: #909399;
font-size: 12px;
margin-bottom: 10px;
gap: 6px;
.link-view {
margin-top: 0;
}
}
.btn-row {
display: flex;
@@ -461,43 +458,4 @@ export default {
.add-btn {
width: 100%;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
&:last-child {
margin-bottom: 0;
}
.setting-label {
font-size: 13px;
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: 12px;
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

@@ -1,63 +1,121 @@
<template>
<div class="panel-coupon">
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" style="width: 200px" />
<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>
<slider-field
v-model="firstItem.count"
label="显示数量"
:min="1"
:max="10"
/>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<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 ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
layoutStyle: "1",
bgColor: "#FFFFFF",
paddingX: 0,
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
contentGap: 5,
paddingX: 12,
pageGap: 10,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "优惠券", count: 3 };
const DEFAULT_ITEM = { title: "优惠券", count: 5 };
export default {
name: "PanelCoupon",
components: { ColorField, SliderField },
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS };
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() {
@@ -75,4 +133,13 @@ export default {
<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,646 @@
<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="brand">商品品牌</el-radio>
<el-radio value="store">指定商户</el-radio>
<el-radio value="goods">指定商品</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-if="firstItem.dataMode === 'store'">
<div class="ctrl-row">
<div class="ctrl-label">指定商户</div>
<div class="ctrl-body">
<el-select v-model="firstItem.storeId" placeholder="全部商户" clearable filterable>
<el-option
v-for="s in storeList"
:key="s.id"
:label="s.storeName"
:value="s.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 { getCategoryTree, getBrandListData } from "@/api/goods";
import { getShopListData } from "@/api/shops";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
toneMode: "theme",
priceColor: "#FF5C58",
salesColor: "#666666",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 10,
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: "name",
value: "id",
children: "children",
checkStrictly: true,
emitPath: false,
},
categoryTree: [],
brandList: [],
storeList: [],
};
},
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);
getCategoryTree().then((res) => {
if (res && res.success) this.categoryTree = res.result || [];
});
getBrandListData().then((res) => {
this.brandList = Array.isArray(res) ? res : (res && res.result) || [];
});
getShopListData({ pageNumber: 1, pageSize: 100, status: "OPEN" }).then((res) => {
if (res && res.success) this.storeList = (res.result && res.result.records) || [];
});
},
methods: {
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;
getCategoryTree().then((res) => {
if (res && res.success) this.categoryTree = res.result || [];
});
getBrandListData().then((res) => {
this.brandList = Array.isArray(res) ? res : (res && res.result) || [];
});
getShopListData({ pageNumber: 1, pageSize: 100, status: "OPEN" }).then((res) => {
if (res && res.success) this.storeList = (res.result && res.result.records) || [];
});
},
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,
goodsName: item.goodsName,
price: item.price,
thumbnail: item.thumbnail,
}));
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

@@ -1,63 +1,227 @@
<template>
<div class="panel-integral">
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" style="width: 200px" />
<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>
<slider-field
v-model="firstItem.count"
label="显示数量"
:min="1"
:max="20"
/>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<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-radio-group v-model="res.options.headBgType">
<el-radio value="image">背景图片</el-radio>
<el-radio value="color">背景色</el-radio>
</el-radio-group>
</div>
</div>
<gradient-field
v-if="res.options.headBgType === 'color'"
v-model:start="res.options.headBg1"
v-model:end="res.options.headBg2"
label="背景颜色"
:default-start="defaults.headBg1"
:default-end="defaults.headBg2"
/>
<div class="ctrl-row" v-else>
<div class="ctrl-label">背景图片</div>
<div class="ctrl-body">
<div class="bg-preview" v-if="res.options.headBgImage">
<img :src="res.options.headBgImage" alt="" />
</div>
<el-button plain type="primary" @click="pickFile('headBgImage')">上传背景图</el-button>
</div>
</div>
<div class="section-tip" v-if="res.options.headBgType !== 'color'">建议 710px*96px</div>
<div class="ctrl-row">
<div class="ctrl-label">标题类型</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.titleType">
<el-radio value="image">图片</el-radio>
<el-radio value="text">文字</el-radio>
</el-radio-group>
</div>
</div>
<div class="ctrl-row" v-if="res.options.titleType === 'image'">
<div class="ctrl-label">标题图片</div>
<div class="ctrl-body">
<div class="icon-preview" v-if="res.options.titleImage">
<img :src="res.options.titleImage" alt="" />
</div>
<el-button plain type="primary" @click="pickFile('titleImage')">上传标题图</el-button>
</div>
</div>
<div class="ctrl-row" v-else>
<div class="ctrl-label">标题文字</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" maxlength="10" placeholder="积分兑好礼" />
</div>
</div>
<slider-field v-model="firstItem.count" label="展示数量" :min="1" :max="20" />
</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-3">
<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"
label="内容背景"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.pointColor"
label="积分价格颜色"
:default-value="defaults.pointColor"
/>
<div class="ctrl-row">
<div class="ctrl-label">标题文字</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in titleStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (res.options.titleStyle || 'bold') === opt.value }"
@click="res.options.titleStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<color-field
v-model="res.options.titleColor"
label="标题颜色"
:default-value="defaults.titleColor"
/>
<color-field
v-model="res.options.moreColor"
label="更多按钮颜色"
:default-value="defaults.moreColor"
/>
</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="20" />
<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 ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
const DEFAULTS = {
bgColor: "#FFFFFF",
paddingX: 0,
pageGap: 10,
borderRadius: 0,
layoutStyle: "1",
pointColor: "#F5483A",
headBgType: "color",
headBg1: "#FF6A00",
headBg2: "#FF2200",
headBgImage: "",
titleType: "text",
titleImage: "",
titleStyle: "bold",
titleColor: "#FFFFFF",
moreColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
contentGap: 12,
pageGap: 0,
borderRadius: 7,
imgRadius: 5,
};
const DEFAULT_ITEM = { title: "积分商城", count: 3 };
const DEFAULT_ITEM = { title: "积分兑好礼", count: 3 };
export default {
name: "PanelIntegral",
components: { ColorField, SliderField },
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS };
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
titleStyleOptions: [
{ value: "bold", title: "加粗" },
{ value: "normal", title: "正常" },
{ value: "italic", title: "倾斜" },
],
layoutOptions: [
{ value: "1", title: "样式一" },
{ value: "2", title: "样式二" },
{ value: "3", title: "样式三" },
],
picModelFlag: false,
currentField: "",
};
},
computed: {
firstItem() {
@@ -70,9 +234,70 @@ export default {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
},
methods: {
pickFile(field) {
this.currentField = field;
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.currentField) {
this.res.options[this.currentField] = val.url;
}
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-integral {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.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;
}
}
.icon-preview {
width: 88px;
height: 24px;
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: contain;
}
}
</style>

View File

@@ -1,48 +1,48 @@
<template>
<div class="panel-line">
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<color-field
v-model="res.options.lineColor"
label="线条颜色"
:default-value="defaults.lineColor"
/>
<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="100"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<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,
@@ -52,7 +52,8 @@ const DEFAULTS = {
export default {
name: "PanelLine",
components: { ColorField, SliderField, SegmentField },
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, SegmentField },
props: {
res: { type: Object, required: true },
},
@@ -71,3 +72,15 @@ export default {
},
};
</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

@@ -16,9 +16,9 @@
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="panel-card panel-card-fill">
<div class="section-title">菜单设置</div>
<div class="section-tip">每行展示 5 个,建议图标比例一致</div>
<div class="section-tip">推荐尺寸:88*88px(1:1)</div>
<div class="menu-item-card" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>菜单 {{ index + 1 }}</span>
@@ -42,10 +42,10 @@
</div>
<div class="icon-placeholder" v-else>图标</div>
<div class="menu-item-fields">
<el-input v-model="item.title" size="small" maxlength="6" placeholder="菜单名称" />
<el-input v-model="item.title" maxlength="6" placeholder="菜单名称" />
<div class="btn-row">
<el-button plain type="primary" size="small" @click="pickImage(item)">选择图标</el-button>
<el-button plain type="primary" size="small" @click="pickLink(item)">选择链接</el-button>
<el-button plain type="primary" @click="pickImage(item)">选择图标</el-button>
<el-button plain type="primary" @click="pickLink(item)">选择链接</el-button>
</div>
</div>
</div>
@@ -58,19 +58,71 @@
</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.menuDisplay === 'single' }"
@click="res.options.menuDisplay = 'single'"
>
单行
</div>
<div
class="seg-item"
:class="{ active: res.options.menuDisplay !== 'single' }"
@click="res.options.menuDisplay = 'multi'"
>
多行
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="res.options.menuDisplay !== 'single'">
<div class="ctrl-label">显示行数</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="n in [2, 3, 4]"
:key="n"
class="seg-item"
:class="{ active: res.options.menuRows === n }"
@click="res.options.menuRows = n"
>
{{ n }}行
</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="n in [3, 4, 5]"
:key="n"
class="seg-item"
:class="{ active: res.options.menuCols === n }"
@click="res.options.menuCols = n"
>
{{ n }}个
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<color-field
v-model="res.options.bgColor1"
label="背景颜色"
:default-value="defaults.bgColor1"
<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.bgColor2"
label="渐变结束色"
:default-value="defaults.bgColor2"
/>
<div class="section-tip">起始色与结束色一致时为纯色背景</div>
<color-field
v-model="res.options.textColor"
label="文字颜色"
@@ -82,15 +134,15 @@
<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="30" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="50" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card">
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="50" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="50" />
<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>
@@ -111,11 +163,15 @@
<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 "@/components/lili-dialog/wap.js";
const DEFAULTS = {
menuDisplay: "single",
menuRows: 3,
menuCols: 4,
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
textColor: "#282828",
@@ -131,7 +187,7 @@ const DEFAULTS = {
export default {
name: "PanelMenu",
hideDecorateTitle: true,
components: { ColorField, SliderField, ossManage, Top, Bottom, Delete },
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
@@ -216,11 +272,13 @@ export default {
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-menu {
background: #f5f7fa;
margin: 0 -20px -120px;
padding: 0 12px 120px;
min-height: calc(100vh - 70px);
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
@@ -235,7 +293,7 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 14px;
font-size: 16px;
font-weight: bold;
color: #303133;
}
@@ -268,13 +326,13 @@ export default {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 12px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
@@ -288,7 +346,7 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-item-ops {
@@ -313,14 +371,15 @@ export default {
}
.menu-item-body {
display: flex;
gap: 10px;
gap: 12px;
align-items: stretch;
}
.icon-preview {
width: 44px;
height: 44px;
width: 56px;
height: 56px;
border: 1px dashed #dcdfe6;
border-radius: 8px;
padding: 3px;
padding: 4px;
box-sizing: border-box;
flex-shrink: 0;
img {
@@ -331,15 +390,15 @@ export default {
}
}
.icon-placeholder {
width: 44px;
height: 44px;
width: 56px;
height: 56px;
border: 1px dashed #dcdfe6;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
font-size: 13px;
flex-shrink: 0;
}
.menu-item-fields {
@@ -347,11 +406,16 @@ export default {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: space-between;
gap: 10px;
}
.btn-row {
display: flex;
gap: 10px;
.el-button {
flex: 1;
margin: 0;
}
}
.link-view {
margin-top: 8px;

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 "@/components/lili-dialog/wap.js";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 0,
paddingBottom: 0,
paddingX: 12,
pageGap: 10,
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,446 @@
<template>
<div class="panel-promotion">
<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" v-if="!isPintuan">
<div class="section-title">图标设置</div>
<div class="section-tip">支持上传 1 张图片,建议 124*32px</div>
<div class="icon-row">
<div class="icon-preview" v-if="res.options.icon">
<img :src="res.options.icon" alt="" />
</div>
<div class="icon-placeholder" v-else>图标</div>
<el-button plain type="primary" @click="pickImage">上传图标</el-button>
</div>
</div>
<div class="panel-card" v-if="isPintuan">
<div class="section-title">头部设置</div>
<div class="ctrl-row">
<div class="ctrl-label">选择风格</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.headBgType">
<el-radio value="color">背景色</el-radio>
<el-radio value="image">背景图片</el-radio>
</el-radio-group>
</div>
</div>
<template v-if="res.options.headBgType === 'image'">
<div class="ctrl-row">
<div class="ctrl-label">背景图片</div>
<div class="ctrl-body">
<div class="bg-preview" v-if="res.options.headBgImage">
<img :src="res.options.headBgImage" alt="" />
</div>
<el-button plain type="primary" @click="pickImage('headBg')">上传背景图</el-button>
</div>
</div>
<div class="section-tip">建议 710px*96px</div>
</template>
<gradient-field
v-else
v-model:start="res.options.headBgColor1"
v-model:end="res.options.headBgColor2"
:default-start="defaults.headBgColor1"
:default-end="defaults.headBgColor2"
label="头部背景"
/>
<div class="ctrl-row">
<div class="ctrl-label">标题类型</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.headTitleType">
<el-radio value="text">文字</el-radio>
<el-radio value="image">图片</el-radio>
</el-radio-group>
</div>
</div>
<div class="ctrl-row" v-if="res.options.headTitleType !== 'image'">
<div class="ctrl-label">标题文字</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" maxlength="10" placeholder="超值拼团" />
</div>
</div>
<template v-else>
<div class="ctrl-row">
<div class="ctrl-label">标题图片</div>
<div class="ctrl-body">
<div class="bg-preview" v-if="res.options.headTitleImage">
<img :src="res.options.headTitleImage" alt="" />
</div>
<el-button plain type="primary" @click="pickImage('headTitle')">上传标题图</el-button>
</div>
</div>
<div class="section-tip">建议 200px*40px</div>
</template>
</div>
<div class="panel-card" v-if="!isPintuan">
<div class="section-title">标题设置</div>
<div class="ctrl-row">
<div class="ctrl-label">标题内容</div>
<div class="ctrl-body">
<el-input
v-model="firstItem.title1"
maxlength="10"
:placeholder="isSeckill ? '邀请你的好友来参与' : (isKanjia ? '邀请好友来砍价' : '精彩抢先看')"
/>
</div>
</div>
<div class="ctrl-row" v-if="!isLive && !isSeckill && !isKanjia">
<div class="ctrl-label">更多链接</div>
<div class="ctrl-body">
<span class="link-text" v-if="res.options.moreUrl && res.options.moreUrl.___type">
{{ linkText(res.options.moreUrl) }}
</span>
<el-button plain type="primary" @click="pickLink">选择链接</el-button>
</div>
</div>
</div>
<div class="panel-card panel-card-fill" v-if="!isLive">
<div class="section-title">显示内容</div>
<div class="ctrl-row">
<div class="ctrl-label">展示信息</div>
<div class="ctrl-body">
<el-checkbox v-model="res.options.showGoodsName">商品名称</el-checkbox>
<el-checkbox v-model="res.options.showGoodsPrice">商品价格</el-checkbox>
<template v-if="isPintuan">
<el-checkbox v-model="res.options.showGroupTag">活动标签</el-checkbox>
<el-checkbox v-model="res.options.showOriginalPrice">商品原价</el-checkbox>
</template>
</div>
</div>
<div class="ctrl-row" v-if="isPintuan">
<div class="ctrl-label">拼团按钮</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.showGroupBtn">
<el-radio :value="true">显示</el-radio>
<el-radio :value="false">隐藏</el-radio>
</el-radio-group>
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card" v-if="isPintuan || isLive || isSeckill || isKanjia">
<div class="section-title">布局设置</div>
<div class="ctrl-row">
<div class="ctrl-label">展示样式</div>
<div class="ctrl-body">
<div class="seg layout-seg" :class="{ 'layout-seg-3': isLive && !isPintuan }">
<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"
/>
<color-field
v-model="res.options.titleColor"
label="标题颜色"
:default-value="defaults.titleColor"
/>
<color-field
v-if="!isSeckill && !isKanjia"
v-model="res.options.moreColor"
label="更多按钮颜色"
:default-value="defaults.moreColor"
/>
<color-field
v-model="res.options.nameColor"
label="商品名称颜色"
:default-value="defaults.nameColor"
/>
</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" />
<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 GradientField from "../controls/GradientField.vue";
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/components/lili-dialog/wap.js";
const DEFAULTS = {
layoutStyle: "1",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
moreColor: "#282828",
nameColor: "#282828",
paddingTop: 10,
paddingBottom: 10,
contentGap: 10,
paddingX: 12,
pageGap: 10,
borderRadius: 0,
imgRadius: 10,
showGoodsName: true,
showGoodsPrice: true,
showGroupTag: true,
showOriginalPrice: true,
showGroupBtn: true,
headBgType: "color",
headBgColor1: "#F5483A",
headBgColor2: "#FF7E3D",
headTitleType: "text",
};
// 秒杀参考 CRMEB 默认:背景红→白渐变、标题白色、圆角 背景 7 / 内容 5
const SECKILL_DEFAULTS = {
...DEFAULTS,
bgColor1: "#FF2200",
bgColor2: "#FFFFFF",
titleColor: "#FFFFFF",
borderRadius: 7,
imgRadius: 5,
};
export default {
name: "PanelPromotion",
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
pickTarget: "icon",
};
},
computed: {
firstItem() {
return (this.res.options.list && this.res.options.list[0]) || {};
},
isPintuan() {
return this.res.promotionsType === "PINTUAN" || this.firstItem.type === "PINTUAN";
},
isLive() {
return this.res.promotionsType === "LIVE" || this.firstItem.type === "LIVE";
},
isSeckill() {
return (
this.res.promotionsType === "SECKILL" || this.firstItem.type === "SECKILL"
);
},
isKanjia() {
return (
this.res.promotionsType === "KANJIA" || this.firstItem.type === "KANJIA"
);
},
layoutOptions() {
if (this.isLive && !this.isPintuan) {
return [
{ value: "1", title: "单行" },
{ value: "2", title: "多行" },
{ value: "3", title: "双排" },
];
}
if ((this.isSeckill || this.isKanjia) && !this.isPintuan) {
return [
{ value: "1", title: "样式一" },
{ value: "2", title: "样式二" },
];
}
return [
{ value: "1", title: "样式一" },
{ value: "2", title: "样式二" },
{ value: "3", title: "样式三" },
{ value: "4", title: "样式四" },
];
},
},
created() {
const defaults =
["SECKILL", "KANJIA"].includes(this.res.promotionsType)
? SECKILL_DEFAULTS
: DEFAULTS;
this.defaults = defaults;
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(target = "icon") {
this.pickTarget = target;
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) {
const target = this.pickTarget || "icon";
if (target === "headBg") {
this.res.options.headBgImage = val.url;
} else if (target === "headTitle") {
this.res.options.headTitleImage = val.url;
} else {
this.res.options.icon = 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.res.options.moreUrl = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-promotion {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.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;
}
.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

@@ -1,46 +1,62 @@
<template>
<div class="panel-rich-text">
<el-tabs v-model="activeTab">
<el-tab-pane label="内容" name="content">
<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" />
</el-tab-pane>
<el-tab-pane label="样式" name="style">
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
</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"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
</el-tab-pane>
</el-tabs>
</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 "@/components/editor";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
content: "",
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingX: 12,
pageGap: 10,
borderRadius: 0,
@@ -48,15 +64,35 @@ const DEFAULTS = {
export default {
name: "PanelRichText",
components: { editor, ColorField, SliderField },
hideDecorateTitle: true,
components: { editor, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS, activeTab: "content" };
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

@@ -1,63 +1,270 @@
<template>
<div class="panel-shop">
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" style="width: 200px" />
<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>
<slider-field
v-model="firstItem.count"
label="显示数量"
:min="1"
:max="20"
/>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">图标设置</div>
<div class="section-tip">支持上传 1 张图片,建议 124*32px</div>
<div class="icon-row">
<div class="icon-preview" v-if="firstItem.icon">
<img :src="firstItem.icon" alt="" />
</div>
<div class="icon-placeholder" v-else>图标</div>
<el-button plain type="primary" @click="pickImage">上传图标</el-button>
</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-input v-model="firstItem.title" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">更多链接</div>
<div class="ctrl-body">
<span class="link-text" v-if="firstItem.moreUrl && firstItem.moreUrl.___type">
{{ linkText(firstItem.moreUrl) }}
</span>
<el-button plain type="primary" @click="pickLink">选择链接</el-button>
</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.showLogo">店铺logo</el-checkbox>
<el-checkbox v-model="firstItem.showName">店铺名称</el-checkbox>
</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="default">默认规则</el-radio>
<el-radio value="custom">自定义</el-radio>
</el-radio-group>
</div>
</div>
<slider-field
v-if="firstItem.dataMode !== 'custom'"
v-model="firstItem.count"
label="展示数量"
:min="1"
:max="20"
/>
<template v-else>
<div class="ctrl-row">
<div class="ctrl-label">选择店铺</div>
<div class="ctrl-body">
<el-button plain type="primary" @click="shopDialogVisible = true">选择店铺</el-button>
</div>
</div>
<div class="shop-chips" v-if="firstItem.shopList && firstItem.shopList.length">
<el-tag
v-for="(shop, i) in firstItem.shopList"
:key="shop.id"
closable
class="shop-chip"
@close="removeShop(i)"
>
{{ shop.storeName }}
</el-tag>
</div>
</template>
</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: (firstItem.shopStyle || 'one') === 'one' }"
@click="firstItem.shopStyle = 'one'"
>
样式一
</div>
<div
class="seg-item"
:class="{ active: firstItem.shopStyle === 'two' }"
@click="firstItem.shopStyle = 'two'"
>
样式二
</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.titleColor"
label="标题颜色"
:default-value="defaults.titleColor"
/>
<color-field
v-model="res.options.nameColor"
label="店铺名称颜色"
:default-value="defaults.nameColor"
/>
<color-field
v-model="res.options.moreColor"
label="更多按钮颜色"
:default-value="defaults.moreColor"
/>
</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>
<el-dialog
v-model="shopDialogVisible"
title="选择店铺"
width="720px"
append-to-body
destroy-on-close
@open="loadShops"
>
<el-table
:data="shopOptions"
v-loading="shopLoading"
max-height="400"
@selection-change="onShopSelection"
>
<el-table-column type="selection" width="50" />
<el-table-column label="店铺" min-width="220">
<template #default="{ row }">
<div class="shop-cell">
<img :src="row.storeLogo" class="shop-cell-logo" alt="" />
<span>{{ row.storeName }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ row.selfOperated ? "自营" : "旗舰店" }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="shopDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmShops">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/components/lili-dialog/wap.js";
import { getShopListData } from "@/api/shops";
const DEFAULTS = {
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
nameColor: "#282828",
moreColor: "#282828",
paddingTop: 10,
paddingBottom: 10,
paddingX: 0,
pageGap: 10,
borderRadius: 0,
imgRadius: 7,
};
const DEFAULT_ITEM = {
title: "店铺列表",
count: 4,
icon: "",
moreUrl: "",
showLogo: true,
showName: true,
shopStyle: "one",
dataMode: "default",
shopList: [],
};
const DEFAULT_ITEM = { title: "店铺列表", count: 4 };
export default {
name: "PanelShop",
components: { ColorField, SliderField },
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS };
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
shopDialogVisible: false,
shopLoading: false,
shopOptions: [],
selectedShops: [],
};
},
computed: {
firstItem() {
@@ -69,10 +276,141 @@ export default {
if (!Array.isArray(this.res.options.list) || !this.res.options.list[0]) {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
const item = this.firstItem;
if (item.showLogo === undefined) item.showLogo = true;
if (item.showName === undefined) item.showName = true;
if (!item.shopStyle) item.shopStyle = "one";
if (!item.dataMode) item.dataMode = "default";
if (!Array.isArray(item.shopList)) item.shopList = [];
},
methods: {
// 加载店铺列表供选择
loadShops() {
this.shopLoading = true;
this.selectedShops = [];
getShopListData({ pageNumber: 1, pageSize: 100, status: "OPEN" })
.then((res) => {
this.shopOptions = (res && res.result && res.result.records) || [];
})
.finally(() => {
this.shopLoading = false;
});
},
onShopSelection(rows) {
this.selectedShops = rows;
},
confirmShops() {
this.firstItem.shopList = this.selectedShops.map((s) => ({
id: s.id,
storeName: s.storeName,
storeLogo: s.storeLogo,
selfOperated: s.selfOperated,
storeBanner: s.storeBanner || "",
}));
this.shopDialogVisible = false;
},
removeShop(index) {
this.firstItem.shopList.splice(index, 1);
},
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.firstItem.icon = 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.firstItem.moreUrl = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.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;
}
.shop-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.shop-cell {
display: flex;
align-items: center;
gap: 8px;
.shop-cell-logo {
width: 28px;
height: 28px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
}
.link-text {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 10px;
}
</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 "@/components/lili-dialog/wap.js";
const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 10,
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: 10,
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

@@ -11,6 +11,12 @@ import PanelFlexOne from "./PanelFlexOne.vue";
import PanelCarousel from "./PanelCarousel.vue";
import PanelAdCube from "./PanelAdCube.vue";
import PanelMenu from "./PanelMenu.vue";
import PanelSearch from "./PanelSearch.vue";
import PanelNotice from "./PanelNotice.vue";
import PanelVideo from "./PanelVideo.vue";
import PanelTitle from "./PanelTitle.vue";
import PanelPromotion from "./PanelPromotion.vue";
import PanelGoods from "./PanelGoods.vue";
// 图片魔方及其全部可选风格共用 PanelAdCube
const adCubeTypes = [
@@ -37,5 +43,11 @@ export default {
flexOne: PanelFlexOne,
carousel: PanelCarousel,
menu: PanelMenu,
search: PanelSearch,
notice: PanelNotice,
video: PanelVideo,
title: PanelTitle,
promotionDetail: PanelPromotion,
goods: PanelGoods,
...Object.fromEntries(adCubeTypes.map((t) => [t, PanelAdCube])),
};

View File

@@ -113,11 +113,11 @@ export const modelData = [
list: [
{
title: "标题",
color: "#fff",
title1: '领取',
color1: "#fff",
bk_color: "#FF0000",
textAlign: "center",
color: "#282828",
title1: '副标题',
color1: "#999999",
bk_color: "#FFFFFF",
textAlign: "left",
link: "",
url: '',
model: "link"
@@ -473,14 +473,6 @@ export const modelData = [
title: "标题",
size: "88*88 (1:1)",
model: "link"
},
{
img: "https://i.loli.net/2020/12/05/eznDa3iNby5FkYL.png",
url: "",
link: "",
title: "标题",
size: "88*88 (1:1)",
model: "link"
}
]
}
@@ -502,74 +494,6 @@ export const modelData = [
]
}
},
{
type: "goods",
name: "商品分类",
icon: "Goods",
group: "base",
img: "md-image",
notAdd: true,
notLink: true,
notImg: true,
options: {
list: [
{
/**
* 2021/12/9
* 新增索引(‘index’)判断商品归属分类
* 之前代码没有配置index也不会收到印象
* 新建的楼层将采用索引判断分类
*/
titleWay: [
{
title: "精选",
desc: "电子推荐",
___index: 0,
bindCategory:""
},
{
title: "实惠",
desc: "便宜好货",
___index: 1,
bindCategory:""
},
{
title: "进口",
desc: "国际自营",
___index: 2,
bindCategory:""
},
{
title: "推荐",
desc: "喂奶推荐",
___index: 3,
bindCategory:""
}
],
listWay: [
{
img: "https://i.loli.net/2021/05/14/KTLSrOVJmEdX12A.png",
price: "120",
title:
" 微软 (Microsoft) Xbox 无线控制器/手柄 湛蓝色 | 3.5mm耳机接口蓝牙连接 Xbox主机电脑平板通用",
type: "精选",
___index: 0
},
{
img: "https://i.loli.net/2020/12/05/c9mptI5Pg8qJ6ny.png",
title:
"宏碁(acer) DP高清线1.2版 2K*4KDisplayPort公对公接线笔记本电脑显卡连接显示器视频线1.5米",
price: "190",
type: "精选",
___index: 0
},
]
}
]
}
},
{
type: "notice",
name: "公告",
@@ -606,7 +530,66 @@ export const modelData = [
subColor: "#fff",
img: "md-image",
options: {
list: []
list: [
{
type: "PINTUAN",
title: "超值拼团",
title1: "惊喜折扣",
bk_color: "#e1212b",
color1: "#fff",
groupSuccess: "134人拼团成功",
data: [
{
goodsName: "iPhone 16 Pro 5G智能手机 256GB 白色钛金属",
price: 8999,
originalPrice: 9999,
thumbnail: "/demo/apple-phone.png",
groupNum: 2,
joined: 3
},
{
goodsName: "Xiaomi 15 5G智能手机 12GB+256GB 白色",
price: 3999,
originalPrice: 4299,
thumbnail: "/demo/xiaomi-phone.png",
groupNum: 2,
joined: 8
},
{
goodsName: "vivo X200 5G智能手机 12GB+256GB 宝石蓝",
price: 4199,
originalPrice: 4599,
thumbnail: "/demo/vivo-phone.png",
groupNum: 2,
joined: 5
},
{
goodsName: "iPhone 16 5G智能手机 128GB 群青色",
price: 5999,
originalPrice: 6499,
thumbnail: "/demo/apple-phone.png",
groupNum: 2,
joined: 12
},
{
goodsName: "Redmi K80 5G智能手机 12GB+256GB 玄夜黑",
price: 2499,
originalPrice: 2799,
thumbnail: "/demo/xiaomi-phone.png",
groupNum: 2,
joined: 21
},
{
goodsName: "vivo S20 5G智能手机 8GB+256GB 凤羽金",
price: 2299,
originalPrice: 2599,
thumbnail: "/demo/vivo-phone.png",
groupNum: 2,
joined: 16
}
]
}
]
}
},
{
@@ -624,7 +607,37 @@ export const modelData = [
subColor: "#fff",
img: "md-image",
options: {
list: []
layoutStyle: "1",
list: [
{
type: "LIVE",
title: "精彩直播",
title1: "精彩抢先看",
bk_color: "#e1212b",
color1: "#fff",
data: [
{
title: "直播标题直播标题直播标题直播标题",
anchorName: "樱桃小丸子",
status: "LIVE",
cover: "/demo/apple-phone.png"
},
{
title: "直播标题直播标题直播标题直播标题",
anchorName: "樱桃小丸子",
status: "REPLAY",
cover: "/demo/xiaomi-phone.png"
},
{
title: "直播标题直播标题直播标题直播标题",
anchorName: "樱桃小丸子",
status: "PREVIEW",
startTime: "7/29 10:00",
cover: "/demo/vivo-phone.png"
}
]
}
]
}
},
{
@@ -642,7 +655,38 @@ export const modelData = [
subColor: "#fff",
img: "md-image",
options: {
list: []
layoutStyle: "1",
list: [
{
type: "SECKILL",
title: "限时秒杀",
title1: "邀请你的好友来参与",
bk_color: "#e1212b",
color1: "#fff",
data: [
{
goodsName: "一家很有品的店",
price: 234,
thumbnail: "/demo/apple-phone.png"
},
{
goodsName: "一家很有品的店",
price: 177,
thumbnail: "/demo/xiaomi-phone.png"
},
{
goodsName: "一家很有品的店",
price: 177,
thumbnail: "/demo/vivo-phone.png"
},
{
goodsName: "一家很有品的店",
price: 234,
thumbnail: "/demo/apple-phone.png"
}
]
}
]
}
},
{
@@ -660,7 +704,42 @@ export const modelData = [
subColor: "#fff",
img: "md-image",
options: {
list: []
layoutStyle: "1",
list: [
{
type: "KANJIA",
title: "砍价免费拿",
title1: "邀请好友来砍价",
bk_color: "#e1212b",
color1: "#fff",
data: [
{
goodsName: "一家很有品的店",
price: 234,
purchasePrice: 99,
thumbnail: "/demo/apple-phone.png"
},
{
goodsName: "一家很有品的店",
price: 177,
purchasePrice: 77,
thumbnail: "/demo/xiaomi-phone.png"
},
{
goodsName: "一家很有品的店",
price: 177,
purchasePrice: 77,
thumbnail: "/demo/vivo-phone.png"
},
{
goodsName: "一家很有品的店",
price: 234,
purchasePrice: 99,
thumbnail: "/demo/apple-phone.png"
}
]
}
]
}
},
{
@@ -754,11 +833,39 @@ export const modelData = [
group: "marketing",
img: "md-image",
options: {
list: [{ title: "积分商城", count: 3 }],
bgColor: "#FFFFFF",
paddingX: 0,
pageGap: 10,
borderRadius: 0
layoutStyle: "1",
list: [
{
title: "积分兑好礼",
count: 6,
data: [
{
goodsName: "一家很有品的店",
points: 200,
originalPrice: 234,
thumbnail: "/demo/apple-phone.png"
},
{
goodsName: "一家很有品的店",
points: 150,
originalPrice: 177,
thumbnail: "/demo/xiaomi-phone.png"
},
{
goodsName: "一家很有品的店",
points: 150,
originalPrice: 177,
thumbnail: "/demo/vivo-phone.png"
},
{
goodsName: "一家很有品的店",
points: 200,
originalPrice: 234,
thumbnail: "/demo/apple-phone.png"
}
]
}
]
}
},
{
@@ -768,11 +875,50 @@ export const modelData = [
group: "marketing",
img: "md-image",
options: {
list: [{ title: "优惠券", count: 3 }],
bgColor: "#FFFFFF",
paddingX: 0,
pageGap: 10,
borderRadius: 0
layoutStyle: "1",
list: [
{
title: "优惠券",
count: 5,
data: [
{
id: 1,
couponType: "PRICE",
price: 70,
consumeThreshold: 500,
storeName: "platform"
},
{
id: 2,
couponType: "PRICE",
price: 50,
consumeThreshold: 300,
storeName: "platform"
},
{
id: 3,
couponType: "PRICE",
price: 30,
consumeThreshold: 200,
storeName: "platform"
},
{
id: 4,
couponType: "PRICE",
price: 20,
consumeThreshold: 100,
storeName: "platform"
},
{
id: 5,
couponType: "PRICE",
price: 10,
consumeThreshold: 0,
storeName: "platform"
}
]
}
]
}
},
{
@@ -802,5 +948,48 @@ export const modelData = [
pageGap: 10,
borderRadius: 0
}
}
},
{
type: "goods",
name: "商品列表",
icon: "Goods",
group: "base",
img: "md-image",
notAdd: true,
notLink: true,
notImg: true,
options: {
list: [
{
layout: "list",
showName: true,
showPrice: true,
showSales: true,
cartShow: true,
cartStyle: "plus",
cartAction: "detail",
dataMode: "category",
categoryId: "",
brandId: "",
storeId: "",
goodsList: [],
count: 6,
sortType: "default"
}
],
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
toneMode: "theme",
priceColor: "#FF5C58",
salesColor: "#666666",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 10,
borderRadius: 0,
contentRadius: 7
}
},
];

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

@@ -6,11 +6,12 @@
}
.ctrl-label {
width: 72px;
width: 90px;
flex-shrink: 0;
line-height: 32px;
color: #606266;
font-size: 13px;
font-size: 14px;
white-space: nowrap;
}
.ctrl-body {

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

@@ -75,7 +75,7 @@
padding: 0 20px;
height: calc(100vh - 96px);
overflow-y: auto;
padding-bottom: 120px;
background: #f5f7fa;
}
.decorate-title {

View File

@@ -37,7 +37,7 @@
<!-- 老组件遗留配置分支 -->
<template v-else>
<el-alert
v-if="res.name == '商品分类'"
v-if="res.name == '商品列表'"
type="warning"
title="装修提示"
:closable="false"
@@ -497,7 +497,7 @@
>
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -580,7 +580,7 @@ const hidePanelTitle = computed(
);
// hotzoneRef/ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const hotzoneRef = ref(null);
const ossManageRef = ref(null);
@@ -748,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) {

View File

@@ -32,8 +32,9 @@
</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"
@@ -46,14 +47,14 @@
<template #item="{ element, index }">
<div class="list">
<div class="floor-name">{{ element.name || element.type }}</div>
<component
<div
v-if="templates[element.type]"
class="component"
:class="{ active: selected == index }"
@click="handleComponent(element, index)"
:is="templates[element.type]"
:res="element.options"
></component>
>
<component :is="templates[element.type]" :res="element.options"></component>
</div>
<div
v-else
class="component unknown-component"
@@ -92,6 +93,7 @@
v-if="decorateData"
:res="decorateData"
></decorate>
<PanelPageSetting v-else :res="contentData" />
</div>
</div>
</div>
@@ -132,6 +134,7 @@ 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 * as API_Promotions from "@/api/promotion";
import { getStore } from "@/libs/storage";
@@ -168,8 +171,28 @@ const iconMap = {
const qrcode = ref(""); // 二维码
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(""); // 装修数据
@@ -180,9 +203,39 @@ 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 = [
@@ -232,15 +285,25 @@ function init() {
cancelText: '取消',
onOk: () => {
const data = JSON.parse(cache);
contentData.value = data;
contentData.value = mergePageData(data);
}
});
}
if (!route.query.id) return false;
if (!route.query.id) {
// 新建页面:模板名称取路由参数
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);
const parsed = JSON.parse(res.result.pageData);
contentData.value = mergePageData(parsed);
if (!contentData.value.name) {
contentData.value.name = route.query.name || "";
}
const first = contentData.value.list[0];
if (first && templates[first.type]) {
handleComponent(first, 0);
@@ -416,4 +479,32 @@ 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;
&:hover {
border-color: #409eff;
}
}
.content > .draggable {
background: transparent;
}
.wap-title {
border-bottom: none;
}
</style>

View File

@@ -5,16 +5,17 @@
<el-button size="default" @click="goBack">返回</el-button>
<div class="page-name">
<template v-if="!editingName">
<span class="page-name-label">当前页面:{{ submitWay.name }}</span>
<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="submitWay.name"
v-model="pageName"
size="small"
style="width: 200px"
maxlength="30"
placeholder="请输入模板名称"
@blur="editingName = false"
@keyup.enter="editingName = false"
/>
@@ -43,7 +44,7 @@
<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">
是否立即发布
@@ -66,7 +67,7 @@
</div>
</template>
<script setup>
import { reactive, ref, watch, onMounted, nextTick } from "vue";
import { reactive, ref, watch, onMounted, nextTick, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import * as API_Other from "@/api/other.js";
import { Edit } from "@element-plus/icons-vue";
@@ -119,10 +120,34 @@ function startEditName() {
const submitWay = reactive({
// 表单信息
pageShow: route.query.type || false,
name: route.query.name || "模板名称",
pageClientType: "H5"
});
// 模板名称与「页面设置」面板共用 store 中的同一份数据,双向同步
const pageName = computed({
get() {
const store = appStore.styleStore;
if (store && typeof store === "object") {
return store.name || "";
}
return route.query.name || "";
},
set(val) {
if (!appStore.styleStore || typeof appStore.styleStore !== "object") {
appStore.styleStore = { ...appStore.styleStore, list: [] };
}
appStore.styleStore.name = val;
},
});
onMounted(() => {
// 新建页面时把路由带来的模板名称写入 store(不覆盖已有名称)
const store = appStore.styleStore;
if (store && typeof store === "object" && !store.name && route.query.name) {
store.name = route.query.name;
}
});
watch(
() => props.pagetype,
(val) => {
@@ -171,7 +196,23 @@ function handleSpinShow() {
// 填写是否发布,模板名称之后保存
function save() {
if (
// 楼层装修页面(非开屏/弹窗广告)需校验页面设置的必填项
const isFloorPage =
route.query.pagetype != "ALERT" &&
route.query.pagetype != "OPEN_SCREEN_ANIMATION";
if (isFloorPage) {
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 && route.query.pagetype != 'ALERT' && route.query.pagetype != 'OPEN_SCREEN_ANIMATION')
) {
@@ -183,6 +224,7 @@ function save() {
? (submitWay.pageShow = "OPEN")
: (submitWay.pageShow = "CLOSE");
submitWay.name = pageName.value;
submitWay.pageData = JSON.stringify(appStore.styleStore);
submitWay.pageType = "INDEX";
submitWay.pageType = route.query.pagetype || "INDEX";
@@ -202,7 +244,7 @@ function updateHome() {
progress.value = false;
API_Other.updateHome(route.query.id, {
pageData: JSON.stringify(appStore.styleStore),
name: submitWay.name,
name: pageName.value,
pageShow: submitWay.pageShow,
pageType: submitWay.pageType,
pageClientType: "H5",

View File

@@ -137,7 +137,7 @@
}
.floor-name {
position: absolute;
left: -78px;
right: calc(100% + 8px);
top: 8px;
z-index: 60;
background: #ffffff;
@@ -217,9 +217,10 @@
}
.model-config {
overflow-y: auto;
background: #f5f7fa;
}
.active {
box-sizing: border-box;
border: 1px solid $theme_color;
border: 2px solid $theme_color;
}

View File

@@ -1,36 +1,113 @@
<template>
<div class="tpl-article" :style="wrapperStyle">
<div class="tpl-article" :class="{ styled: styled }" :style="wrapperStyle">
<div class="article-title" v-if="title">{{ title }}</div>
<div class="article-item" v-for="i in 3" :key="i">
<div class="article-text">
<div class="article-name">文章标题示例文章标题示例</div>
<div class="article-time">2026-10-12</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>
<div class="article-thumb"></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.res.list && this.res.list[0] && 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 : 10}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: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
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>
@@ -46,6 +123,10 @@ export default {
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;

View File

@@ -3,21 +3,41 @@
<div class="banner-box" :style="cardMode ? {} : boxStyle">
<el-carousel
class="carousel"
:class="{ 'card-mode': cardMode }"
v-if="res"
v-if="res && !cardMode"
autoplay
:interval="5000"
height="150px"
:type="cardMode ? 'card' : ''"
:indicator-position="customIndicator ? 'none' : ''"
@change="onChange"
>
<el-carousel-item v-for="(item, index) in res.list" :key="index">
<div class="slide-wrap">
<img class="image-mode" :style="boxStyle" :src="item.img" />
<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>
</div>
<div
v-if="customIndicator && res.indicatorStyle !== 'none' && res.list.length > 1"
class="indicator"
@@ -35,12 +55,15 @@
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { ref, computed, watch, onBeforeUnmount } from "vue";
defineOptions({ title: "导航栏" });
const props = defineProps(["res"]);
const current = ref(0);
const previewWidth = ref(0);
const cardPreview = ref(null);
let timer = null;
// 配置过样式字段才启用自定义样式/指示器,老数据保持原样
const customIndicator = computed(
@@ -52,6 +75,10 @@ const indicatorColor = computed(() =>
? 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 {};
@@ -77,6 +104,48 @@ const boxStyle = computed(() => {
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";
@@ -98,16 +167,34 @@ function onChange(index) {
display: block;
overflow: hidden;
}
// 卡片模式:卡片铺满轮播项,去掉 el-carousel 默认卡片白底阴影
.card-mode {
:deep(.el-carousel__item--card) {
background: transparent;
box-shadow: none;
}
:deep(.el-carousel__mask) {
background: transparent;
.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;

View File

@@ -2,7 +2,7 @@
<div
class="tpl-blank"
:style="{
background: res.bgColor ?? '#f5f5f5',
background: blankBg,
height: (res.height ?? 10) + 'px',
margin: `0 ${res.paddingX ?? 0}px ${res.pageGap ?? 10}px`,
}"
@@ -15,5 +15,16 @@ export default {
props: {
res: { type: Object, default: () => ({}) },
},
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

@@ -1,7 +1,9 @@
<template>
<div class="tpl-coupon" :style="wrapperStyle">
<div class="tpl-coupon" :class="{ styled: styled }" :style="wrapperStyle">
<div class="coupon-title" v-if="title">{{ title }}</div>
<div class="coupon-list">
<!-- 老数据保持原样 -->
<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">
@@ -15,6 +17,98 @@
</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>
@@ -25,19 +119,58 @@ export default {
res: { type: Object, default: () => ({}) },
},
computed: {
firstItem() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return (this.res.list && this.res.list[0] && 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 : 10}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: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
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>
@@ -91,4 +224,218 @@ export default {
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,97 +1,447 @@
<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="12" height="12" 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() {
return { gap: this.px(this.res.contentGap) };
},
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: 12px;
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: 13px;
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: 18px;
height: 18px;
border-radius: 50%;
font-size: 13px;
line-height: 1;
}
&.cart-text {
border-radius: 9px;
padding: 2px 8px;
font-size: 11px;
}
}
/* 卡片:左图右文 */
.mode-card {
flex-direction: column;
.g-item {
flex-direction: row;
align-items: center;
padding: 6px;
}
.g-imgbox {
width: 55px;
height: 55px;
margin-right: 8px;
}
.g-name {
margin-bottom: 4px;
}
}
/* 列表:两列网格 */
.mode-list {
.g-item {
width: calc(50% - 3px);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 6px;
min-height: 52px;
}
.g-name {
margin-bottom: 4px;
}
}
/* 三列 */
.mode-three {
.g-item {
width: calc(33.33% - 4px);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 4px;
min-height: 44px;
}
.g-name {
-webkit-line-clamp: 1;
font-size: 11px;
margin-bottom: 2px;
}
.g-price {
font-size: 12px;
}
.g-sales {
font-size: 10px;
}
}
/* 大图:一行一个 */
.mode-big {
flex-direction: column;
.g-item {
width: 100%;
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 3 / 2;
}
.g-info {
padding: 6px;
min-height: 44px;
}
.g-name {
margin-bottom: 4px;
}
}
/* 旧版样式 */
.selected {
> h4 {
color: #000 !important;
@@ -105,7 +455,6 @@ function handleClickTitle(val, index) {
padding: 10px;
transition: 0.35s;
display: flex;
// cursor: pointer;
> .goods-item-title {
flex: 1;
text-align: center;
@@ -156,9 +505,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 +516,4 @@ function handleClickTitle(val, index) {
}
}
}
.goods-icon {
right: 5px;
top: 5px;
position: absolute;
}
</style>

View File

@@ -1,27 +1,69 @@
<template>
<div class="layout" :style="wrapperStyle">
<div class="join-list">
<div class="join-title">
<div>{{ title }}</div>
<div>更多</div>
<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">更多 &gt;</div>
</div>
<!-- 老数据:原始四列样式 -->
<div v-if="!styled" 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>
<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>
@@ -33,18 +75,117 @@ import { computed } from "vue";
const props = defineProps(["res"]);
const title = computed(
() => (props.res.list && props.res.list[0] && props.res.list[0].title) || "积分商城"
() => (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 displayData = computed(() => {
const data = (firstItem.value && firstItem.value.data) || [];
return layoutStyle.value === "1" ? data.slice(0, 4) : data.slice(0, 6);
});
// 面板保存过样式字段才启用新样式,老数据保持原样
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 : 10}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(() =>
styled.value ? { gap: px(props.res.contentGap) } : {}
);
const imgRadiusStyle = computed(() =>
styled.value ? { borderRadius: px(props.res.imgRadius), overflow: "hidden" } : {}
);
const wrapperStyle = computed(() => ({
background: props.res.bgColor || "#FFFFFF",
padding: `0 ${props.res.paddingX || 0}px`,
marginBottom: `${props.res.pageGap !== undefined ? props.res.pageGap : 10}px`,
borderRadius: `${props.res.borderRadius || 0}px`,
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;
}
@@ -57,12 +198,7 @@ const wrapperStyle = computed(() => ({
}
.join-item {
flex: 1;
}
.item-img {
width: 75px;
height: 75px;
margin: 0 auto;
display: block;
text-align: center;
}
.item-img-box {
position: relative;
@@ -75,26 +211,114 @@ const wrapperStyle = computed(() => ({
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(50% - 5px);
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(33.33% - 7px);
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

@@ -2,7 +2,7 @@
<template>
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-height-150" :style="imgStyle">
<img class="image-mode" :src="res.list[0].img" />
<img alt="模板图片" class="image-mode" :src="res.list[0].img" />
</div>
<div class="view-height-150">
<div class="view-height-75" :style="imgStyle">

View File

@@ -10,7 +10,7 @@
</div>
</div>
<div class="view-height-150" :style="imgStyle">
<img class="image-mode" style="height:150px;" :src="res.list[2].img" />
<img alt="模板图片" class="image-mode" style="height:150px;" :src="res.list[2].img" />
</div>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<div
class="tpl-line"
:style="{
background: res.bgColor ?? '#FFFFFF',
background: lineBg,
padding: `0 ${res.paddingX ?? 0}px`,
marginBottom: (res.pageGap ?? 10) + 'px',
}"
@@ -21,5 +21,16 @@ export default {
props: {
res: { type: Object, default: () => ({}) },
},
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,7 +1,16 @@
<template>
<div class="layout" :style="wrapperStyle">
<div class="menu-list" :class="{ styled: styled }" :style="listStyle">
<div class="menu-item" v-for="(item, index) in res.list" :key="index">
<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>
@@ -26,6 +35,22 @@ const listStyle = computed(() =>
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";
@@ -34,14 +59,21 @@ const titleStyle = computed(() =>
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 {
&.styled > .menu-item,
&.single > .menu-item {
margin: 0;
width: auto;
}
}
.menu-img-wrap {

View File

@@ -1,8 +1,13 @@
<template>
<div class="layout">
<div class="background" :style="{ backgroundColor: res.list[0].bk_color }">
<img :src="res.list[0].img" alt="" style="float:left;width: 20px;height: 20px;margin-top: 10px;margin-left: 10px;margin-right: 10px">
<div class="title" :style="{ color: res.list[0].color }">
<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>
@@ -10,51 +15,109 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ref, computed, watch, onBeforeUnmount } from "vue";
defineOptions({ title: "公告" });
const props = defineProps(["res"]);
defineOptions({ title: "公告" });
const props = defineProps(["res"]);
const index = ref(0);
const context = ref("");
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);
});
onMounted(() => {
context.value = props.res.list[0].title[index.value].context;
// 历史遗留:定时器未在卸载时清理,保持原行为
setInterval(() => {
context.value = props.res.list[0].title[index.value].context;
if (index.value < props.res.list[0].title.length - 1) {
index.value++;
} else {
index.value = 0;
}
}, 3000);
});
// 面板保存过样式字段才启用自定义样式,老数据保持原样
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 {
position: absolute;
z-index: 2;
width: 100%;
height: 42px;
text-align: left;
font-size: 10px;
background-size: cover;
}
.layout {
text-align: center;
position: relative;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.title {
line-height: 42px;
font-size: 10px;
font-weight: bold;
}
@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>

View File

@@ -2,7 +2,7 @@
<div
class="tpl-rich-text"
:style="{
background: res.bgColor ?? '#FFFFFF',
background: richBg,
padding: `0 ${res.paddingX ?? 12}px`,
marginBottom: (res.pageGap ?? 10) + 'px',
borderRadius: (res.borderRadius ?? 0) + 'px',
@@ -19,6 +19,17 @@ export default {
props: {
res: { type: Object, default: () => ({}) },
},
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>

View File

@@ -1,26 +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

@@ -1,45 +1,265 @@
<template>
<div class="tpl-shop" :style="wrapperStyle">
<div class="shop-title" v-if="title">{{ title }}</div>
<div class="shop-grid">
<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">更多 &gt;</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-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">
<img v-if="showLogo" class="shop-street-logo" :src="shop.logo" alt="" />
<div v-if="showName" class="shop-street-name" :style="nameStyle">{{ shop.name }}</div>
<img
v-if="shop.goodsImage"
class="shop-street-banner"
:style="logoStyle"
:src="shop.goodsImage"
alt=""
/>
</div>
<div class="shop-street-goods">
<div
class="street-goods-item"
v-for="(goods, goodsIndex) in shop.goods"
:key="goodsIndex"
>
<img class="street-goods-img" :src="goods.img" alt="" />
<div class="street-goods-name">{{ goods.name }}</div>
<div class="street-goods-price">¥{{ $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.res.list && this.res.list[0] && 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,
goods,
goodsImage: goods[0]?.img || s.goodsImage || s.storeBanner || "",
};
});
}
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 : 10}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: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
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: {
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-title {
font-size: 15px;
font-weight: 600;
.shop-head {
display: flex;
align-items: center;
padding: 4px 12px 10px;
}
.shop-grid {
.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;
@@ -62,4 +282,132 @@ export default {
color: #333;
margin-left: 8px;
}
/* 样式一 */
.shop-grid {
display: flex;
gap: 8px;
padding: 0 12px;
}
.shop-card {
flex: 1;
min-width: 0;
border-radius: 6px;
overflow: hidden;
background: #f7f7f7;
}
.shop-card-bg {
width: 100%;
height: 56px;
display: block;
object-fit: cover;
background: #eee;
}
.shop-card-bg-empty {
background: linear-gradient(135deg, #f5f5f5, #e8e8e8);
}
.shop-card-body {
padding: 4px;
text-align: center;
}
.shop-card-logo {
position: relative;
z-index: 1;
display: block;
width: 60px;
height: 24px;
margin: -12px auto 0;
object-fit: contain;
background: #fff;
}
.shop-card-name {
font-size: 12px;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 样式二:店铺卡片 + 商品列表 */
.shop-street-list {
padding: 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.shop-street-card {
border-radius: 6px;
overflow: hidden;
background: #f7f7f7;
}
.shop-street-head {
position: relative;
display: flex;
align-items: center;
height: 34px;
padding: 0 8px;
box-sizing: border-box;
background: $theme_color;
overflow: hidden;
}
.shop-street-logo {
flex-shrink: 0;
width: 44px;
height: 16px;
padding: 1px 3px;
box-sizing: border-box;
object-fit: contain;
background: #fff;
border-radius: 2px;
}
.shop-street-name {
max-width: calc(100% - 110px);
margin-left: 6px;
color: #fff;
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-street-banner {
position: absolute;
top: 0;
right: 0;
width: 80px;
height: 34px;
object-fit: cover;
}
.shop-street-goods {
display: flex;
gap: 6px;
padding: 6px;
box-sizing: border-box;
}
.street-goods-item {
flex: 1;
min-width: 0;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.street-goods-img {
width: 100%;
height: 58px;
display: block;
object-fit: cover;
background: #eee;
}
.street-goods-name {
padding: 4px 4px 0;
color: #282828;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.street-goods-price {
padding: 2px 4px 5px;
color: $theme_color;
font-size: 11px;
font-weight: 600;
}
</style>

View File

@@ -1,56 +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 || "更多" }} &gt;
</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: px(item.value.fontSize || 20),
fontWeight: item.value.textStyle === "bold" ? "bold" : "normal",
fontStyle: item.value.textStyle === "italic" ? "italic" : "normal",
}));
const subStyle = computed(() => ({
color: item.value.color1 || "#282828",
fontSize: px(item.value.subFontSize || 14),
}));
const moreStyle = computed(() => ({
color: item.value.moreColor || "#282828",
fontSize: px(item.value.moreFontSize || 12),
}));
</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

@@ -2,7 +2,7 @@
<template>
<div class="layout" :style="[wrapperStyle, gapStyle]">
<div class="view-width-100" :style="imgStyle">
<img class="image-mode" :src="res.list[0].img" />
<img alt="模板图片" class="image-mode" :src="res.list[0].img" />
</div>
<div class="view-width-100" :style="gapStyle">
<div class="view-height-85" :style="imgStyle">

View File

@@ -9,7 +9,7 @@
</div>
</div>
<div class="view-width-100" :style="imgStyle">
<img class="image-mode" :src="res.list[2].img" />
<img alt="模板图片" class="image-mode" :src="res.list[2].img" />
</div>
</div>
</template>

View File

@@ -44,8 +44,36 @@ const item = computed(() => {
ensureVideoItem(data);
return data;
});
const wrapperStyle = computed(() => getVideoWrapperStyle(item.value));
const boxStyle = computed(() => getVideoBoxStyle(item.value));
// 面板保存过样式字段(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>

View File

@@ -10,14 +10,20 @@
/>
</el-tabs>
<div class="operation mb_10">
<el-button type="primary" @click="handleAdd()">添加页面</el-button>
<el-button type="primary" tag="a" :href="addHref" target="_blank" class="add-page-btn">添加页面</el-button>
</div>
<el-table v-loading="loading" :data="list" style="width: 100%">
<el-table-column prop="name" label="页面名称" min-width="200" show-overflow-tooltip>
<div class="page-table-wrap">
<el-table v-loading="loading" :data="list" style="width: 100%">
<el-table-column prop="name" label="页面名称" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.name || "暂无模板昵称" }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
@@ -30,16 +36,17 @@
/>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="handleEdit(row)">修改</a>
<a class="link-text" :href="editHref(row)" target="_blank">修改</a>
<span class="op-split">|</span>
<a class="link-text" @click="confirmDel(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="params.pageNumber"
@@ -57,10 +64,11 @@
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import * as API_Other from "@/api/other.js";
import { Message, Modal } from "@/utils/message";
import { unixToDate } from "@/utils/filters";
const router = useRouter();
@@ -131,6 +139,12 @@ function beforeRelease(row) {
});
});
}
function formatTime(time) {
if (!time) return "—";
const ts = new Date(time).getTime();
if (Number.isNaN(ts)) return time;
return unixToDate(ts / 1000);
}
function init() {
loading.value = true;
API_Other.getHomeList(params).then((res) => {
@@ -147,8 +161,8 @@ function init() {
total.value = res.result.total;
});
}
function handleEdit(val) {
router.push({
function editHref(val) {
const { href } = router.resolve({
path: "/floorList/main",
query: {
id: val.id,
@@ -157,13 +171,15 @@ function handleEdit(val) {
pagetype: params.pageType,
},
});
return href;
}
function handleAdd() {
router.push({
path: "/floorList/main",
query: { pagetype: params.pageType },
});
}
const addHref = computed(
() =>
router.resolve({
path: "/floorList/main",
query: { pagetype: params.pageType },
}).href
);
function changePageSize(v) {
params.pageNumber = 1;
params.pageSize = v;
@@ -191,4 +207,11 @@ function handleDel(val) {
.page-type-tabs {
margin-bottom: 16px;
}
.add-page-btn {
text-decoration: none;
}
.page-table-wrap {
max-width: 100%;
overflow-x: auto;
}
</style>

View File

@@ -31,6 +31,9 @@ module.exports = {
port: configs.port,
client: {
overlay: {
// 编译警告(如上游分销模块未完成的导出)不再弹全屏浮层,真实错误仍然弹出
warnings: false,
errors: true,
runtimeErrors: (error) => {
const message = error?.message || "";
if (message.includes("ResizeObserver loop")) {