feat(buyer,manager): 楼层装修组件体系与平台端页面优化

- buyer:楼层装修组件(RowWrapper、组件库、预设、注册表)及首页/商品列表适配
- manager:PC端楼层装修页面、店铺设置、OSS素材管理等页面样式优化
This commit is contained in:
pikachu1995@126.com
2026-10-28 17:41:03 +08:00
parent f4fd1c1acd
commit bb8e9a0d3d
176 changed files with 28736 additions and 293 deletions

View File

@@ -26,17 +26,16 @@
</template>
</el-dialog>
<el-drawer
<el-dialog
v-else
v-model="flag"
class="link-selector-drawer"
direction="rtl"
size="1160px"
:with-header="false"
width="1160px"
top="80px"
:z-index="10000"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="link-selector-dialog"
@close="clickClose"
>
<linkDialog
@@ -52,7 +51,7 @@
<el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button>
</template>
</el-drawer>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";

View File

@@ -51,6 +51,11 @@ import {
Star,
Document,
User,
UserFilled,
Service,
Ticket,
Bell,
OfficeBuilding,
Promotion,
PriceTag,
Sunny,
@@ -61,27 +66,33 @@ import {
ArrowRight,
Check,
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
const emit = defineEmits(["selected"]);
const route = useRoute();
const linkList = ref([
{ title: "首页", icon: markRaw(House), ___type: "home" },
{ title: "购物车", icon: markRaw(ShoppingCart), ___type: "cart" },
{ title: "收藏商品", icon: markRaw(Star), ___type: "collection" },
{ title: "我的订单", icon: markRaw(Document), ___type: "order" },
{ title: "个人中心", icon: markRaw(User), ___type: "user" },
{ title: "拼团频道", icon: markRaw(Promotion), ___type: "group" },
{ title: "秒杀频道", icon: markRaw(Promotion), ___type: "seckill" },
{ title: "领券中心", icon: markRaw(PriceTag), ___type: "coupon" },
{ title: "签到", icon: markRaw(Sunny), ___type: "sign" },
{ title: "小程序直播", icon: markRaw(VideoCamera), ___type: "live" },
{ title: "砍价", icon: markRaw(Share), ___type: "kanjia" },
{ title: "积分商城", icon: markRaw(ShoppingBag), ___type: "point" },
{ title: "首页", icon: markRaw(House), url: "/", ___type: "home" },
{ title: "购物车", icon: markRaw(ShoppingCart), url: "/cart", ___type: "cart" },
{ title: "我的订单", icon: markRaw(Document), url: "/home/MyOrder", ___type: "order" },
{ title: "个人中心", icon: markRaw(User), url: "/home", ___type: "user" },
{ title: "基本信息", icon: markRaw(UserFilled), url: "/home/Profile", ___type: "userInfo" },
{ title: "我的售后", icon: markRaw(Service), url: "/home/AfterSale", ___type: "afterSale" },
{ title: "我的优惠券", icon: markRaw(Ticket), url: "/home/Coupons", ___type: "myCoupons" },
{ title: "收藏商品", icon: markRaw(Star), url: "/home/Favorites", ___type: "collection" },
{ title: "消息中心", icon: markRaw(Bell), url: "/home/MsgList", ___type: "msg" },
{ title: "商家入驻", icon: markRaw(OfficeBuilding), url: "/shopEntry", ___type: "merchantEntry" },
{ title: "领券中心", icon: markRaw(PriceTag), url: "/coupon", ___type: "coupon" },
{ title: "秒杀频道", icon: markRaw(Promotion), url: "/seckill", ___type: "seckill" },
{ title: "拼团频道", icon: markRaw(Promotion), url: "/group", ___type: "group" },
{ title: "签到", icon: markRaw(Sunny), url: "/sign", ___type: "sign" },
{ title: "小程序直播", icon: markRaw(VideoCamera), url: "/live", ___type: "live" },
{ title: "砍价", icon: markRaw(Share), url: "/kanjia", ___type: "kanjia" },
{ title: "积分商城", icon: markRaw(ShoppingBag), url: "/point", ___type: "point" },
]);
const linkItem = ref({
title: "外部链接",
title: "自定义链接",
icon: markRaw(Link),
___type: "link",
url: "",
@@ -110,13 +121,37 @@ function showLinkItem(item) {
);
}
function handleLink(val, index) {
val = { ...val, ___type: "other" };
if (val.title === "自定义链接" || val.title === "外部链接") {
ElMessageBox.prompt("请输入链接地址(支持站内相对路径如 /cart 或外部网址如 https://...):", "自定义链接", {
confirmButtonText: "确定",
cancelButtonText: "取消",
inputValue: val.url || "",
inputPlaceholder: "例如 /cart 或 https://...",
inputPattern: /^\S+$/,
inputErrorMessage: "链接地址不能为空",
})
.then(({ value }) => {
const itemData = {
...val,
title: "自定义链接",
url: value,
originalType: "link",
___type: "other",
};
selectedIndex.value = index;
emit("selected", [itemData]);
})
.catch(() => {});
return;
}
const itemData = {
...val,
originalType: val.___type,
___type: "other",
};
selectedIndex.value = index;
emit("selected", [val]);
emit("selected", [itemData]);
}
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
</script>
<style lang="scss" scoped>
@import "../style.scss";

View File

@@ -54,7 +54,9 @@
<el-dialog
v-model="picModalFlag"
title="选择图片"
width="1200px"
width="1160px"
top="16px"
class="pc-image-picker-dialog"
append-to-body
destroy-on-close
@closed="resetPicModal"

View File

@@ -21,6 +21,16 @@ export const floorDecorationRouter = {
component: () => import("@/views/page-decoration/wap/main.vue")
};
// PC端楼层装修编辑器:全屏独立页,不在 Main(左侧菜单)布局内
export const pcFloorDecorationRouter = {
path: "/floorList/renovation",
name: "renovation",
meta: {
title: "PC楼层装修"
},
component: () => import("@/views/page-decoration/pc/index.vue")
};
// 作为Main组件的子页面展示但是不在左侧菜单显示的路由写在otherRouter里
export const otherRouter = {
path: "/",
@@ -359,12 +369,7 @@ export const otherRouter = {
name: "seckill-goods",
component: () => import("@/views/promotions/seckill/seckill-goods.vue")
},
{
path: "/floorList/renovation",
title: "编辑模板",
name: "renovation",
component: () => import("@/views/page-decoration/renovation.vue")
},
{
path: "add-sms-sign",
title: "短信签名",
@@ -549,4 +554,4 @@ export const externalLink = {
};
// 所有上面定义的路由都要写在下面的routers里
export const routers = [loginRouter, floorDecorationRouter, otherRouter, page500, page403, externalLink];
export const routers = [loginRouter, floorDecorationRouter, pcFloorDecorationRouter, otherRouter, page500, page403, externalLink];

View File

@@ -271,6 +271,10 @@ export function formatLinkType (item) {
break;
case 'other':
if (item.url) {
url = item.url;
break;
}
switch (item.title) {
case '首页':
url = '/';
@@ -281,13 +285,29 @@ export function formatLinkType (item) {
case '我的订单':
url = '/home/MyOrder';
break;
case '收藏商品':
url = '/home/Favorites';
break;
case '个人中心':
url = '/home';
break;
case '基本信息':
url = '/home/Profile';
break;
case '我的售后':
url = '/home/AfterSale';
break;
case '我的优惠券':
url = '/home/Coupons';
break;
case '收藏商品':
url = '/home/Favorites';
break;
case '消息中心':
url = '/home/MsgList';
break;
case '商家入驻':
url = '/shopEntry';
break;
case '外部链接':
case '自定义链接':
url = item.url;
break;
case '秒杀频道':

View File

@@ -17,10 +17,10 @@
style="width: 240px"
/>
</el-form-item>
<el-form-item label="商品ID" prop="id">
<el-form-item label="SPU编码" prop="id">
<el-input
v-model="searchForm.id"
placeholder="请输入商品ID"
placeholder="请输入SPU编码"
clearable
style="width: 240px"
/>
@@ -126,7 +126,7 @@
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="100" align="center" />
<el-table-column prop="id" label="商品ID" width="200" show-overflow-tooltip />
<el-table-column prop="id" label="SPU编码" width="200" show-overflow-tooltip />
<el-table-column label="商品图片" width="100" align="center">
<template #default="{ row }">
<img alt="商品图片"

View File

@@ -105,7 +105,7 @@
</div>
</div>
<div class="goods-section-bd">
<el-form-item label="商品ID">
<el-form-item label="SPU编码">
<span class="detail-value">{{ goods.id || "—" }}</span>
</el-form-item>
<el-form-item label="店铺名称">

View File

@@ -17,10 +17,10 @@
style="width: 240px"
/>
</el-form-item>
<el-form-item label="商品ID" prop="goodsId">
<el-form-item label="SPU编码" prop="goodsId">
<el-input
v-model="searchForm.goodsId"
placeholder="请输入商品ID"
placeholder="请输入SPU编码"
clearable
style="width: 240px"
/>
@@ -78,7 +78,7 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="SKU ID" width="180" show-overflow-tooltip />
<el-table-column prop="goodsId" label="商品ID" width="180" show-overflow-tooltip />
<el-table-column prop="goodsId" label="SPU编码" width="180" show-overflow-tooltip />
<el-table-column label="商品信息" min-width="360">
<template #default="{ row }">
<div v-if="row" class="goods-info">

View File

@@ -645,7 +645,7 @@ function loadOnlineUsers() {
});
}
function buildShareUrl() {
const base = (wapUrl.value || BASE?.WAP_URL || config.website || "").replace(/\/?$/, "/");
const base = (wapUrl.value || BASE?.WAP_URL || localStorage.getItem("website") || config.website || "").replace(/\/?$/, "/");
return `${base}pages/promotion/live/room?id=${liveId.value}`;
}
function handleShare() {

View File

@@ -242,7 +242,7 @@ function handleControl(row) {
}
function buildH5ShareUrl(liveId) {
// 历史遗留:BASE 未定义,wapUrl 为空时会抛 ReferenceError,保持原样
const base = (wapUrl.value || BASE?.WAP_URL || config.website || "").replace(/\/?$/, "/");
const base = (wapUrl.value || BASE?.WAP_URL || localStorage.getItem("website") || config.website || "").replace(/\/?$/, "/");
return `${base}pages/promotion/live/room?id=${liveId}`;
}
function handleShare(row) {

View File

@@ -1,13 +1,13 @@
<template>
<div class="foot">
<el-row justify="space-around" class="help">
<a class="item" :href="config.website" target="_blank">帮助</a>
<a class="item" :href="config.website" target="_blank">隐私</a>
<a class="item" :href="config.website" target="_blank">条款</a>
<a class="item" :href="website" target="_blank">帮助</a>
<a class="item" :href="website" target="_blank">隐私</a>
<a class="item" :href="website" target="_blank">条款</a>
</el-row>
<el-row justify="center" class="copyright">
Copyright © {{ year }} - Present
<a :href="config.website" class="href" target="_blank" style="margin: 0 5px">{{ config.title }}</a>
<a :href="website" class="href" target="_blank" style="margin: 0 5px">{{ config.title }}</a>
</el-row>
</div>
</template>
@@ -17,6 +17,8 @@ import { ref } from "vue";
import config from "@/config/index";
const year = ref(new Date().getFullYear());
// 官网地址优先取接口下发缓存,兜底用本地配置
const website = ref(localStorage.getItem("website") || config.website);
</script>
<style lang="scss" scoped>

View File

@@ -26,7 +26,7 @@ function applyFavicon(href) {
function getSite() {
getBaseSite().then((res) => {
if (res.success && res.result && res.result.settingValue) {
const { domainLogo: logo, domainIcon, siteName } = JSON.parse(
const { domainLogo: logo, domainIcon, siteName, website } = JSON.parse(
res.result.settingValue
);
domainLogo.value = logo || "";
@@ -35,6 +35,7 @@ function getSite() {
localStorage.setItem("icon", logo || "");
localStorage.setItem("domainIcon", domainIcon || "");
localStorage.setItem("title", siteName || "");
localStorage.setItem("website", website || "");
applyFavicon(domainIcon || logo);
if (siteName) {
window.document.title = siteName + " - 运营后台";

View File

@@ -114,7 +114,7 @@
<a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
</div>
<div style="color: #999; font-size: 12px; margin-top: 5px">
商品ID: {{ row.goodsId }}
SPU编码: {{ row.goodsId }}
</div>
</div>
</div>

View File

@@ -10,7 +10,7 @@
/>
</el-tabs>
<div class="operation mb_10">
<el-button type="primary" @click="createTemp">添加页面</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>
@@ -18,6 +18,16 @@
<span v-if="row">{{ row.name || "暂无模板昵称" }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row?.createTime) }}
</template>
</el-table-column>
<el-table-column prop="updateTime" label="修改时间" width="180">
<template #default="{ row }">
{{ formatTime(row?.updateTime) }}
</template>
</el-table-column>
<el-table-column
v-if="searchForm.pageType !== 'SPECIAL'"
label="状态"
@@ -35,12 +45,10 @@
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="Template(row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="decorate(row)">装修</a>
<a class="link-text" :href="decorateHref(row)" target="_blank">装修</a>
<span class="op-split">|</span>
<a class="link-text" @click="confirmDel(row.id)">删除</a>
</template>
@@ -61,43 +69,21 @@
/>
</div>
</el-card>
<el-dialog
v-model="showModal"
title="模板设置"
width="600px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<el-form ref="form" :model="formData" label-width="80px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入模板名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showModal = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="newTemplate">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import * as API_floor from "@/api/other.js";
import { Message, Modal } from "@/utils/message";
import { unixToDate } from "@/utils/filters";
defineOptions({ name: "floorList" });
const router = useRouter();
const showModal = ref(false);
const total = ref(0);
const formData = ref({
status: false,
name: "",
});
const searchForm = reactive({
pageNumber: 1,
pageSize: 20,
@@ -113,37 +99,48 @@ const pageTypes = ref([
]);
const list = ref([]);
const form = ref(null);
const addHref = computed(() => {
return router.resolve({
path: "/floorList/renovation",
query: {
pageType: searchForm.pageType,
mode: "add",
},
}).href;
});
function decorateHref(row) {
return router.resolve({
path: "/floorList/renovation",
query: {
id: row.id,
name: row.name,
pageType: searchForm.pageType,
},
}).href;
}
onMounted(() => {
getTemplateList();
window.addEventListener("focus", onWindowFocus);
});
function newTemplate() {
form.value.validate((valid) => {
if (valid) {
const data = formData.value;
data.status ? (data.pageShow = "OPEN") : (data.pageShow = "CLOSE");
delete data.status;
(data.pageType = searchForm.pageType),
(data.pageClientType = "PC");
if (data.id) {
API_floor.updateHome(data.id, data).then((res) => {
Message.success("编辑模板成功");
showModal.value = false;
getTemplateList();
});
} else {
API_floor.setHomeSetup(data).then((res) => {
Message.success("新建模板成功");
showModal.value = false;
getTemplateList();
});
}
} else {
loading.value = false;
}
});
onBeforeUnmount(() => {
window.removeEventListener("focus", onWindowFocus);
});
function onWindowFocus() {
getTemplateList();
}
function formatTime(time) {
if (!time) return "—";
if (typeof time === "string" && time.includes("-")) {
return time;
}
const ts = new Date(time).getTime();
if (Number.isNaN(ts)) return time;
return unixToDate(ts / 1000);
}
function handleTabChange(type) {
@@ -152,27 +149,6 @@ function handleTabChange(type) {
getTemplateList();
}
function createTemp() {
form.value?.resetFields();
formData.value = { status: false, name: "" };
showModal.value = true;
}
function Template(item) {
item.status = item.pageShow;
formData.value = { ...item };
showModal.value = true;
}
function decorate(val) {
const data = { id: val.id, pageShow: val.pageShow, pageType: searchForm.pageType };
router.push({
name: "renovation",
query: data,
});
}
function changePageNum(val) {
searchForm.pageNumber = val;
getTemplateList();
@@ -186,9 +162,9 @@ function getTemplateList() {
loading.value = true;
API_floor.getHomeList(searchForm).then((res) => {
loading.value = false;
if (res.success) {
total.value = res.result.total;
list.value = res.result.records;
if (res.success && res.result) {
total.value = res.result.total || 0;
list.value = res.result.records || [];
list.value.forEach((e) => {
if (e.pageShow === "OPEN") {
e.pageShow = true;
@@ -197,6 +173,8 @@ function getTemplateList() {
}
});
}
}).catch(() => {
loading.value = false;
});
}
@@ -223,6 +201,7 @@ function beforeRelease(row) {
});
});
}
function confirmDel(id) {
Modal.confirm({
title: "提示",
@@ -230,6 +209,7 @@ function confirmDel(id) {
onOk: () => delTemplate(id),
});
}
function delTemplate(id) {
API_floor.removePageHome(id).then((res) => {
if (res.success) {
@@ -244,4 +224,8 @@ function delTemplate(id) {
.page-type-tabs {
margin-bottom: 16px;
}
.add-page-btn {
text-decoration: none;
}
</style>

View File

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

View File

@@ -0,0 +1,246 @@
<template>
<div class="panel-article-content">
<!-- 1. 文章标题与元信息 -->
<div class="panel-section">
<div class="section-title">文章标题与元信息</div>
<div class="form-item">
<span class="item-label">展示文章标题</span>
<el-switch v-model="options.showTitle" size="small" />
</div>
<template v-if="options.showTitle !== false">
<div class="form-item">
<span class="item-label">文章主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:品牌故事 · 匠心传承" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:关于我们的品牌理念与品质坚持" />
</div>
<div class="form-item">
<span class="item-label">专栏标签</span>
<el-input v-model="options.tag" size="small" placeholder="如:品牌专栏" />
</div>
</template>
<div class="form-item">
<span class="item-label">展示作者与时间</span>
<el-switch v-model="options.showMeta" size="small" />
</div>
<template v-if="options.showMeta !== false">
<div class="form-item">
<span class="item-label">作者/部门</span>
<el-input v-model="options.author" size="small" placeholder="如:官方商城运营部" />
</div>
<div class="form-item">
<span class="item-label">发布日期</span>
<el-input v-model="options.date" size="small" placeholder="如:2026-10-28" />
</div>
</template>
</div>
<!-- 2. 文章富文本内容 (与万米一致) -->
<div class="panel-section">
<div class="section-title flex-between">
<span>文章内容</span>
<el-button link type="primary" size="small" @click="editMode = (editMode === 'rich' ? 'raw' : 'rich')">
{{ editMode === 'rich' ? '切换为HTML代码' : '切换为预览' }}
</el-button>
</div>
<!-- 纯文本摘要预览 -->
<div class="article-summary-box" v-if="editMode === 'rich'">
<div class="summary-text">{{ cleanTextSummary || '暂无文章内容,请点击下方按钮编辑' }}</div>
</div>
<div class="edit-btn-wrap" v-if="editMode === 'rich'">
<el-button type="primary" size="small" style="width: 100%" @click="openEditorDialog = true">
编辑文章富文本内容
</el-button>
</div>
<!-- 源码编辑模式 -->
<div class="raw-html-editor" v-if="editMode === 'raw'">
<el-input
v-model="options.content"
type="textarea"
:rows="10"
placeholder="请输入或粘贴文章 HTML 内容..."
size="small"
/>
</div>
</div>
<!-- 3. 文章排版样式 -->
<div class="panel-section">
<div class="section-title">文章阅读排版</div>
<div class="form-item">
<span class="item-label">正文字号</span>
<el-radio-group v-model="options.fontSize" size="small">
<el-radio-button :label="14">14px</el-radio-button>
<el-radio-button :label="15">15px</el-radio-button>
<el-radio-button :label="16">16px</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<span class="item-label">正文对齐</span>
<el-radio-group v-model="options.textAlign" size="small">
<el-radio-button label="left">靠左</el-radio-button>
<el-radio-button label="center">居中</el-radio-button>
<el-radio-button label="justify">两端对齐</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 富文本编辑器弹窗 -->
<el-dialog
v-model="openEditorDialog"
title="编辑文章富文本内容"
width="900px"
append-to-body
destroy-on-close
>
<div class="dialog-editor-container">
<tinymce-editor
v-model="tempContent"
height="450px"
/>
</div>
<template #footer>
<el-button @click="openEditorDialog = false">取消</el-button>
<el-button type="primary" @click="handleSaveEditor">确认保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import TinymceEditor from '@/components/editor/index.vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultArticleHtml = `
<h3>关于我们 · 甄选美好生活</h3>
<p>我们始终坚信,一件好的产品不仅能改善日常生活的品质,更传递着对美好生活的执着追求。自创立以来,我们严选全球顶尖供应链,深耕每一道生产工艺与质检环节,力求将兼具美学价值与极致实用性的商品送达每一位用户手中。</p>
<blockquote>品质是我们的信仰,体验是我们的底线。从选材到交付,每一步都经得起时光检验。</blockquote>
<h4>四大核心服务保障</h4>
<ul>
<li><strong>100% 官方正品</strong>:源头品牌直采,全链路防伪溯源,假一赔十保障。</li>
<li><strong>极速顺丰冷链</strong>:全国多仓联动发货,核心城市支持当日达/次日达。</li>
<li><strong>7天无理由退换</strong>:专业客服团队 7×24 小时贴心守护,售后无忧。</li>
<li><strong>会员专享权益</strong>:尊享积分抵现、生日特权礼及专属品鉴沙龙。</li>
</ul>
<p>感谢一路相伴的每一位朋友,未来我们将持续探索更多优质生活场景,与您共同见证品质生活的美好绽放。</p>
`.trim();
if (props.options.showTitle === undefined) props.options.showTitle = true;
if (!props.options.title) props.options.title = '品牌故事 · 匠心传承';
if (!props.options.subtitle) props.options.subtitle = '关于我们的品牌理念与品质坚持';
if (props.options.showMeta === undefined) props.options.showMeta = true;
if (!props.options.author) props.options.author = '官方商城运营部';
if (!props.options.date) props.options.date = '2026-10-28';
if (!props.options.tag) props.options.tag = '品牌专栏';
if (!props.options.fontSize) props.options.fontSize = 15;
if (!props.options.textAlign) props.options.textAlign = 'left';
if (!props.options.content) props.options.content = defaultArticleHtml;
const editMode = ref('rich');
const openEditorDialog = ref(false);
const tempContent = ref(props.options.content || defaultArticleHtml);
watch(openEditorDialog, (val) => {
if (val) {
tempContent.value = props.options.content || defaultArticleHtml;
}
});
const cleanTextSummary = computed(() => {
const c = props.options?.content || '';
const text = c.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
return text.slice(0, 120) + (text.length > 120 ? '...' : '');
});
function handleSaveEditor() {
props.options.content = tempContent.value;
openEditorDialog.value = false;
}
</script>
<style lang="scss" scoped>
.panel-article-content {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.article-summary-box {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
.summary-text {
font-size: 12px;
line-height: 1.6;
color: #475569;
max-height: 72px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.edit-btn-wrap {
margin-top: 6px;
}
.raw-html-editor {
margin-top: 6px;
}
}
.dialog-editor-container {
padding: 10px 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,368 @@
<template>
<div class="panel-channel-floor">
<!-- 1. 标题区设置 -->
<div class="panel-section">
<div class="section-title">标题区设置</div>
<div class="form-item">
<span class="item-label">展示标题区</span>
<el-switch v-model="options.showTitle" size="small" />
</div>
<template v-if="options.showTitle !== false">
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:场景频道" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:主视觉 + 商品组合楼层" />
</div>
</template>
</div>
<!-- 2. 主视觉设置 (Hero Section) -->
<div class="panel-section">
<div class="section-title">场景主视觉</div>
<div class="form-item">
<span class="item-label">主视觉图片</span>
<el-input v-model="options.heroImage" size="small" placeholder="建议尺寸: 520x560px" clearable />
</div>
<div class="form-item">
<span class="item-label">主视觉大标题</span>
<el-input v-model="options.heroTitle" size="small" placeholder="如:品质生活馆" />
</div>
<div class="form-item">
<span class="item-label">主视觉副标题</span>
<el-input v-model="options.heroSubtitle" size="small" placeholder="如:精选场景好物,一站购齐" />
</div>
<div class="form-item">
<span class="item-label">行动按钮文案</span>
<el-input v-model="options.heroButtonText" size="small" placeholder="如:进入频道" />
</div>
<div class="form-item">
<span class="item-label">按钮跳转链接</span>
<el-input v-model="options.heroHref" size="small" placeholder="如:/goods" />
</div>
<div class="form-item">
<span class="item-label">主视觉视觉风格</span>
<el-radio-group v-model="options.heroStyle" size="small">
<el-radio-button label="gradient">主题渐变</el-radio-button>
<el-radio-button label="plain">简洁浅色</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 3. 活动入口 (Promos) -->
<div class="panel-section">
<div class="section-title flex-between">
<span>活动入口 ({{ options.promos?.length || 0 }} 个)</span>
<el-button link type="primary" size="small" @click="handleAddPromo">
<el-icon><Plus /></el-icon> 添加活动
</el-button>
</div>
<div class="promos-list-editor">
<div v-for="(p, pIdx) in options.promos" :key="pIdx" class="promo-editor-card">
<div class="promo-row-1">
<el-input v-model="p.tag" size="small" placeholder="标签(限时/新品)" style="width: 100px" />
<el-input v-model="p.title" size="small" placeholder="活动主标题" style="flex: 1" />
<el-button link type="danger" size="small" @click="options.promos.splice(pIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="promo-row-2">
<el-input v-model="p.desc" size="small" placeholder="活动描述(如: 领券最高减100)" />
</div>
</div>
</div>
</div>
<!-- 4. 场景商品选择 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>场景商品位 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showGoodsModal = true">
选择真实商品
</el-button>
</div>
<div class="form-item">
<span class="item-label">展示商品数量</span>
<el-input-number v-model="options.goodsCount" :min="1" :max="8" size="small" controls-position="right" />
</div>
<div class="goods-list-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="goods-item-row">
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showGoodsModal" title="选择场景商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<template #footer>
<el-button @click="showGoodsModal = false">完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Plus, Delete, Check } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const showGoodsModal = ref(false);
if (props.options.showTitle === undefined) props.options.showTitle = true;
if (!props.options.title) props.options.title = '场景频道';
if (!props.options.subtitle) props.options.subtitle = '主视觉 + 商品组合楼层';
if (!props.options.heroImage) props.options.heroImage = '/static/banner-floor/floor_banner_1.png';
if (!props.options.heroTitle) props.options.heroTitle = '品质生活馆';
if (!props.options.heroSubtitle) props.options.heroSubtitle = '精选场景好物,一站购齐';
if (!props.options.heroButtonText) props.options.heroButtonText = '进入频道';
if (!props.options.heroHref) props.options.heroHref = '/goods';
if (!props.options.heroStyle) props.options.heroStyle = 'gradient';
if (!props.options.promos) {
props.options.promos = [
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时', url: '/goods' },
{ title: '新品驾到', desc: '官方正品首发', tag: '新品', url: '/goods' }
];
}
if (!props.options.goodsCount) props.options.goodsCount = 6;
if (!props.options.goods) {
props.options.goods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' }
];
}
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: '/static/goods/goods_5.png' }
];
function handleAddPromo() {
if (!Array.isArray(props.options.promos)) props.options.promos = [];
props.options.promos.push({
title: '热销特惠',
desc: '全品类甄选底价',
tag: '热卖',
url: '/goods'
});
}
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
} else {
props.options.goods.push({ ...item });
}
}
</script>
<style lang="scss" scoped>
.panel-channel-floor {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.promos-list-editor {
display: flex;
flex-direction: column;
gap: 8px;
.promo-editor-card {
background: #f8fafc;
padding: 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
.promo-row-1 {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
}
}
.goods-list-preview {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #4f46e5;
}
&.selected {
background: #eef2ff;
border-color: #4f46e5;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #4f46e5;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,108 @@
<template>
<div class="panel-coupon">
<div class="panel-section">
<div class="section-title">优惠券卡片排版</div>
<div class="form-item">
<span class="item-label">单行展示列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列</el-radio-button>
<el-radio-button :label="5">5 列</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<span class="item-label">卡券视觉皮肤</span>
<el-radio-group v-model="options.skin" size="small">
<el-radio-button label="red">经典喜庆红</el-radio-button>
<el-radio-button label="dark">黑金尊享风</el-radio-button>
<el-radio-button label="gradient">幻彩渐变</el-radio-button>
</el-radio-group>
</div>
<div class="form-item flex-row">
<span class="item-label">展示使用门槛说明</span>
<el-switch v-model="options.showLimitText" size="small" />
</div>
</div>
<div class="panel-section">
<div class="section-title flex-between">
<span>所选优惠券活动</span>
<el-button link type="primary" size="small" @click="handleSelectCouponDialog">
<el-icon><Plus /></el-icon> 选择优惠券
</el-button>
</div>
<div class="coupon-summary">
已选取 {{ options.couponIds?.length || 0 }} 张优惠券活动。买家端将实时查询当前优惠券可用库存并展示领取状态。
</div>
</div>
</div>
</template>
<script setup>
import { Plus } from '@element-plus/icons-vue';
import { Message } from '@/utils/message';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
function handleSelectCouponDialog() {
Message.info('已开启优惠券关联。演示环境已预设可用券活动。');
if (!Array.isArray(props.options.couponIds)) props.options.couponIds = [];
props.options.couponIds = ['coupon_1', 'coupon_2', 'coupon_3', 'coupon_4'];
}
</script>
<style lang="scss" scoped>
.panel-coupon {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.item-label {
font-size: 12px;
color: #475569;
}
}
.coupon-summary {
font-size: 12px;
color: #64748b;
line-height: 1.6;
background: #f8fafc;
padding: 10px;
border-radius: 6px;
}
}
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<div class="panel-footer-nav">
<!-- 特色服务保障条 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>服务保障条</span>
<el-switch v-model="options.showAssurance" size="small" />
</div>
<div class="assurance-list" v-if="options.showAssurance">
<div
v-for="(item, idx) in options.assurances"
:key="idx"
class="assurance-item"
>
<el-input v-model="item.code" size="small" placeholder="字" class="code-input" />
<el-input v-model="item.title" size="small" placeholder="标题" class="title-input" />
<el-input v-model="item.desc" size="small" placeholder="副标题" class="desc-input" />
<el-button
size="small"
class="action-btn link-btn"
:type="item.url ? 'primary' : 'default'"
:plain="!item.url"
@click="handleSetAssuranceLink(item)"
@contextmenu.prevent="handleClearAssuranceLink(item)"
:title="item.url ? ('已设链接: ' + item.url + ' (点击修改,右键清除)') : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="options.assurances.splice(idx, 1)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button link type="primary" size="small" @click="handleAddAssurance" style="margin-top: 6px">
<el-icon><Plus /></el-icon> 添加保障项
</el-button>
</div>
</div>
<!-- 服务指南与文章分组 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>底部帮助与指南列</span>
<el-button link type="primary" size="small" @click="handleAddGuideGroup">
<el-icon><Plus /></el-icon> 添加分组
</el-button>
</div>
<div class="guide-groups-list">
<div
v-for="(group, gIdx) in options.guideGroups"
:key="gIdx"
class="guide-group-card"
>
<div class="group-header">
<el-input v-model="group.title" size="small" placeholder="列标题(如:购物指南)" style="width: 160px" />
<el-button link type="danger" size="small" @click="options.guideGroups.splice(gIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<!-- 链接列表 -->
<div class="group-links">
<div v-for="(link, lIdx) in group.links" :key="lIdx" class="link-item">
<el-input v-model="link.text" size="small" placeholder="名称" class="link-title-input" />
<el-button
size="small"
class="action-btn link-btn"
:type="link.url ? 'primary' : 'default'"
:plain="!link.url"
@click="handleSetGuideLink(link)"
@contextmenu.prevent="handleClearGuideLink(link)"
:title="link.url ? ('已设链接: ' + link.url + ' (点击修改,右键清除)') : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="group.links.splice(lIdx, 1)" title="删除">
<el-icon><Close /></el-icon>
</el-button>
</div>
<el-button link type="primary" size="small" @click="group.links.push({ text: '新链接', url: '/article' })">
+ 添加子链接
</el-button>
</div>
</div>
</div>
</div>
<!-- 版权与备案 -->
<div class="panel-section">
<div class="section-title">版权与 ICP 备案</div>
<div class="form-item">
<span class="item-label">ICP 备案号</span>
<div class="input-with-action">
<el-input v-model="options.icpText" size="small" placeholder="浙ICP备XXXXXXXX号-1" class="flex-1" />
<el-button
size="small"
class="action-btn link-btn"
:type="options.icpLink ? 'primary' : 'default'"
:plain="!options.icpLink"
@click="handleSetIcpLink"
@contextmenu.prevent="options.icpLink = ''"
:title="options.icpLink ? ('已设链接: ' + options.icpLink + ' (点击修改,右键清除)') : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
</div>
</div>
<div class="form-item">
<span class="item-label">版权声明文案</span>
<div class="input-with-action">
<el-input v-model="options.copyright" size="small" placeholder="Copyright © 2026 Lilishop." class="flex-1" />
<el-button
size="small"
class="action-btn link-btn"
:type="options.copyrightLink ? 'primary' : 'default'"
:plain="!options.copyrightLink"
@click="handleSetCopyrightLink"
@contextmenu.prevent="options.copyrightLink = ''"
:title="options.copyrightLink ? ('已设链接: ' + options.copyrightLink + ' (点击修改,右键清除)') : '设置跳转链接'"
>
<el-icon><Link /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Close, Link } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
const openLinkPicker = inject('openLinkPicker', null);
function handleSetAssuranceLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
item.url = url;
});
} else {
ElMessageBox.prompt('请输入跳转链接路径(留空则清除链接):', `设置 [${item.title || '服务保障'}] 链接`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '',
inputPattern: /^.*$/
})
.then(({ value }) => {
item.url = (value || '').trim();
})
.catch(() => {});
}
}
function handleClearAssuranceLink(item) {
item.url = '';
}
function handleSetGuideLink(link) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
link.url = url;
if (!link.text && title) link.text = title;
});
} else {
ElMessageBox.prompt('请输入跳转链接路径(留空则清除链接):', `设置 [${link.text || '帮助链接'}] 路径`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: link.url || '',
inputPattern: /^.*$/
})
.then(({ value }) => {
link.url = (value || '').trim();
})
.catch(() => {});
}
}
function handleClearGuideLink(link) {
link.url = '';
}
function handleAddAssurance() {
if (!Array.isArray(props.options.assurances)) props.options.assurances = [];
props.options.assurances.push({ code: '优', title: '品质优选', desc: '放心购物', url: '/article' });
}
function handleAddGuideGroup() {
if (!Array.isArray(props.options.guideGroups)) props.options.guideGroups = [];
props.options.guideGroups.push({ title: '新服务指南', links: [{ text: '常见问题', url: '/article' }] });
}
function handleSetIcpLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
props.options.icpLink = url;
});
} else {
ElMessageBox.prompt('请输入 ICP 备案跳转链接(留空则清除链接):', '设置 ICP 备案链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.icpLink || 'https://beian.miit.gov.cn/',
inputPattern: /^.*$/
})
.then(({ value }) => {
props.options.icpLink = (value || '').trim();
})
.catch(() => {});
}
}
function handleSetCopyrightLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
props.options.copyrightLink = url;
});
} else {
ElMessageBox.prompt('请输入版权跳转链接(留空则清除链接):', '设置版权链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.copyrightLink || '',
inputPattern: /^.*$/
})
.then(({ value }) => {
props.options.copyrightLink = (value || '').trim();
})
.catch(() => {});
}
}
</script>
<style lang="scss" scoped>
.panel-footer-nav {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
.item-label {
font-size: 12px;
color: #475569;
}
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
.flex-1 {
flex: 1;
}
.link-btn {
padding: 0 7px;
flex-shrink: 0;
}
}
}
.assurance-list {
display: flex;
flex-direction: column;
gap: 8px;
.assurance-item {
display: flex;
align-items: center;
gap: 6px;
.code-input {
width: 38px;
flex-shrink: 0;
}
.title-input {
width: 82px;
flex-shrink: 0;
}
.desc-input {
flex: 1;
min-width: 0;
}
.link-btn {
padding: 0 7px;
flex-shrink: 0;
}
}
}
.guide-groups-list {
display: flex;
flex-direction: column;
gap: 12px;
.guide-group-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.group-links {
display: flex;
flex-direction: column;
gap: 6px;
.link-item {
display: flex;
align-items: center;
gap: 6px;
.link-title-input {
flex: 1;
}
.link-btn {
padding: 0 7px;
flex-shrink: 0;
}
}
}
}
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,333 @@
<template>
<div class="panel-goods-commerce">
<!-- 1. 楼层标题设置 (部分包含标题的商品楼层可用) -->
<div class="panel-section" v-if="hasTitleConfig">
<div class="section-title">楼层主副标题</div>
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:热门推荐" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:全网热销爆款" />
</div>
<div class="form-item">
<span class="item-label">查看更多链接</span>
<el-input v-model="options.moreUrl" size="small" placeholder="如:/goods" />
</div>
</div>
<!-- 2. 横幅楼层专属横幅海报 (bannerFloor) -->
<div class="panel-section" v-if="component.type === 'bannerFloor'">
<div class="section-title">左侧专属大促海报</div>
<div class="form-item">
<span class="item-label">海报图片 URL</span>
<el-input v-model="options.bannerImg" size="small" placeholder="海报图地址" clearable />
</div>
<div class="form-item">
<span class="item-label">海报跳转地址</span>
<el-input v-model="options.bannerUrl" size="small" placeholder="跳转路径" />
</div>
</div>
<!-- 3. 频道楼层专属多 Tab 设置 (channelFloor) -->
<div class="panel-section" v-if="component.type === 'channelFloor'">
<div class="section-title flex-between">
<span>频道 Tabs 专区设置</span>
<el-button link type="primary" size="small" @click="handleAddTab">
<el-icon><Plus /></el-icon> 添加 Tab
</el-button>
</div>
<div class="tabs-list">
<div v-for="(tab, tIdx) in options.tabs" :key="tIdx" class="tab-item-row">
<el-input v-model="tab.title" size="small" placeholder="Tab 名称" style="width: 110px" />
<el-input v-model="tab.categoryId" size="small" placeholder="绑定的分类 ID" style="flex: 1" />
<el-button link type="danger" size="small" @click="options.tabs.splice(tIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 4. 排版与列数设置 -->
<div class="panel-section" v-if="options.columns !== undefined">
<div class="section-title">排版与列数</div>
<div class="form-item">
<span class="item-label">单行展示列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列</el-radio-button>
<el-radio-button :label="5">5 列</el-radio-button>
<el-radio-button :label="6">6 列</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 5. 数据源设置 -->
<div class="panel-section" v-if="component.type !== 'recommendFeed'">
<div class="section-title">商品数据来源</div>
<div class="form-item">
<span class="item-label">数据源模式</span>
<el-radio-group v-model="options.dataSource" size="small">
<el-radio-button label="manual">手动选品</el-radio-button>
<el-radio-button label="category">分类自动拉取</el-radio-button>
</el-radio-group>
</div>
<div class="form-item" v-if="options.dataSource === 'category'">
<span class="item-label">拉取商品数量</span>
<el-input-number v-model="options.goodsCount" :min="2" :max="20" size="small" controls-position="right" />
</div>
<!-- 手动选品区 -->
<div class="manual-pick-wrap" v-if="options.dataSource === 'manual'">
<div class="pick-header">
<span class="pick-info">已选商品 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showGoodsModal = true">
选择商品
</el-button>
</div>
<!-- 已选商品列表滚动区 -->
<div class="selected-goods-list" v-if="options.goods?.length">
<div
v-for="(g, gIdx) in options.goods"
:key="g.id || gIdx"
class="selected-item"
>
<img :src="g.thumbnail || defaultThumb" alt="" class="item-thumb" />
<div class="item-info">
<span class="item-name">{{ g.goodsName }}</span>
<span class="item-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="removeSelectedGoods(gIdx)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 6. 商品卡片信息显隐开关 -->
<div class="panel-section">
<div class="section-title">商品元素显隐控制</div>
<div class="switch-grid">
<div class="switch-item" v-if="options.showTitle !== undefined">
<span>商品标题</span>
<el-switch v-model="options.showTitle" size="small" />
</div>
<div class="switch-item" v-if="options.showPrice !== undefined">
<span>销售价格</span>
<el-switch v-model="options.showPrice" size="small" />
</div>
<div class="switch-item" v-if="options.showOriginalPrice !== undefined">
<span>原价/划线价</span>
<el-switch v-model="options.showOriginalPrice" size="small" />
</div>
<div class="switch-item" v-if="options.showCartBtn !== undefined">
<span>加购按钮</span>
<el-switch v-model="options.showCartBtn" size="small" />
</div>
<div class="switch-item" v-if="options.showTags !== undefined">
<span>促销标签</span>
<el-switch v-model="options.showTags" size="small" />
</div>
</div>
</div>
<!-- 商品库选择弹窗 -->
<el-dialog
v-model="showGoodsModal"
title="从商品库选择商品"
width="900px"
append-to-body
destroy-on-close
>
<goods-dialog
:selectedWay="options.goods || []"
@selected="handleGoodsSelected"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { Plus, Delete } from '@element-plus/icons-vue';
import GoodsDialog from '@/components/lili-dialog/goods-dialog.vue';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
const defaultThumb = '/static/goods/goods_1.png';
const showGoodsModal = ref(false);
if (props.component?.type === 'goodsList' && (props.options.columns === undefined || props.options.columns === 4)) {
props.options.columns = 6;
}
const hasTitleConfig = computed(() => {
return ['gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'recommendFeed'].includes(props.component.type);
});
function handleAddTab() {
if (!Array.isArray(props.options.tabs)) props.options.tabs = [];
props.options.tabs.push({ title: '新频道专区', categoryId: '', goods: [] });
}
function handleGoodsSelected(selectedGoods) {
if (Array.isArray(selectedGoods)) {
props.options.goods = selectedGoods.map(g => ({
id: g.id,
goodsName: g.goodsName,
price: g.price,
originalPrice: g.originalPrice,
thumbnail: g.thumbnail
}));
}
showGoodsModal.value = false;
}
function removeSelectedGoods(index) {
props.options.goods.splice(index, 1);
}
</script>
<style lang="scss" scoped>
.panel-goods-commerce {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
.item-label {
font-size: 12px;
color: #475569;
}
}
.tabs-list {
display: flex;
flex-direction: column;
gap: 8px;
.tab-item-row {
display: flex;
align-items: center;
gap: 6px;
}
}
.manual-pick-wrap {
background: #f8fafc;
padding: 10px;
border-radius: 6px;
border: 1px solid #e2e8f0;
.pick-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.pick-info {
font-size: 12px;
font-weight: 500;
color: #334155;
}
}
.selected-goods-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
.selected-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
.item-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 2px;
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.item-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-price {
font-size: 12px;
color: #e11d48;
font-weight: 600;
}
}
}
}
}
.switch-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
.switch-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
color: #334155;
}
}
}
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div class="panel-goods-grid">
<!-- 1. 标题与更多设置 -->
<div class="panel-section">
<div class="section-title">宫格标题设置</div>
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:爆款热卖榜" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:近30天爆卖好物精选" />
</div>
<div class="form-item">
<span class="item-label">展示查看更多</span>
<el-switch v-model="options.showMoreLink" size="small" />
</div>
<div class="form-item" v-if="options.showMoreLink !== false">
<span class="item-label">更多文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:查看全部" />
</div>
</div>
<!-- 2. 宫格排版设置 (网格与列数) -->
<div class="panel-section">
<div class="section-title">宫格排版与线条</div>
<div class="form-item">
<span class="item-label">一行展示几个 (列数)</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="4">4 列</el-radio-button>
<el-radio-button :label="5">5 列 (推荐)</el-radio-button>
<el-radio-button :label="6">6 列</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<span class="item-label">矩阵实线网格分割线</span>
<el-switch v-model="options.showBorder" size="small" />
</div>
</div>
<!-- 3. 商品列表与管理 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>宫格商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showModal = true">
选择真实商品
</el-button>
</div>
<div class="goods-grid-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="grid-item-row">
<span class="idx-badge">{{ idx + 1 }}</span>
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showModal" title="选择宫格商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<template #footer>
<el-button @click="showModal = false">完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Delete, Check } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const showModal = ref(false);
if (!props.options.title) props.options.title = '爆款热卖榜';
if (!props.options.subtitle) props.options.subtitle = '近30天爆卖好物精选';
if (props.options.showMoreLink === undefined) props.options.showMoreLink = true;
if (!props.options.moreText) props.options.moreText = '查看全部';
if (!props.options.columns) props.options.columns = 5;
if (props.options.showBorder === undefined) props.options.showBorder = true;
if (!props.options.goods) {
props.options.goods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '10', goodsName: '15.6英寸 4K超高清便携式显示器 Type-C一线直连高色域', price: '1299.00', originalPrice: '1699.00', thumbnail: '/static/goods/goods_3.png' }
];
}
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', thumbnail: '/static/goods/goods_8.png' }
];
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
} else {
props.options.goods.push({ ...item });
}
}
</script>
<style lang="scss" scoped>
.panel-goods-grid {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.goods-grid-preview {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.grid-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
.idx-badge {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
width: 16px;
text-align: center;
}
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #f97316;
}
&.selected {
background: #fff7ed;
border-color: #f97316;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #f97316;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

@@ -0,0 +1,370 @@
<template>
<div class="panel-grid-floor">
<!-- 1. 楼层标题设置 (Floor Header) -->
<div class="panel-section">
<div class="section-title">楼层大标题设置</div>
<div class="form-item">
<span class="item-label">展示楼层标题</span>
<el-switch v-model="options.showFloorTitle" size="small" />
</div>
<div class="form-row-2">
<div class="form-item">
<span class="item-label">楼层编号</span>
<el-input v-model="options.floorNum" size="small" placeholder="如:1F" style="width: 90px;" />
</div>
<div class="form-item flex-1">
<span class="item-label">楼层名称</span>
<el-input v-model="options.title" size="small" placeholder="如:潮流数码" />
</div>
</div>
<div class="form-item">
<span class="item-label">英文/副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:DIGITAL & TECH" />
</div>
</div>
<!-- 2. 右侧子分类快捷标签 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>右侧分类标签</span>
<el-button link type="primary" size="small" @click="handleAddTag">
<el-icon><Plus /></el-icon> 添加标签
</el-button>
</div>
<div class="tags-editor-wrap">
<div v-for="(tag, tIdx) in options.subCategories" :key="tIdx" class="tag-edit-row">
<el-input v-model="options.subCategories[tIdx]" size="small" placeholder="分类名称" />
<el-button link type="danger" size="small" @click="options.subCategories.splice(tIdx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 3. 左侧专属营销竖版海报 -->
<div class="panel-section">
<div class="section-title">左侧专属大促海报</div>
<div class="form-item">
<span class="item-label">展示左侧海报</span>
<el-switch v-model="options.showPoster" size="small" />
</div>
<template v-if="options.showPoster !== false">
<div class="form-item">
<span class="item-label">海报图片 URL</span>
<el-input v-model="options.posterImg" size="small" placeholder="图片链接" clearable />
</div>
<div class="form-item">
<span class="item-label">海报主标题</span>
<el-input v-model="options.posterTitle" size="small" placeholder="如:数码潮电 酷玩季" />
</div>
<div class="form-item">
<span class="item-label">海报副标题</span>
<el-input v-model="options.posterSub" size="small" placeholder="如:领券最高立减 600 元" />
</div>
<div class="form-item">
<span class="item-label">跳转链接</span>
<el-input v-model="options.posterUrl" size="small" placeholder="如:/goods" />
</div>
</template>
</div>
<!-- 4. 右侧商品列数与排版 -->
<div class="panel-section">
<div class="section-title">右侧商品排版</div>
<div class="form-item">
<span class="item-label">单行展示列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列 (推荐)</el-radio-button>
<el-radio-button :label="5">5 列</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 5. 楼层商品选择 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>楼层商品数据 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showGoodsModal = true">
选择真实商品
</el-button>
</div>
<div class="goods-list-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="goods-item-row">
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showGoodsModal" title="选择楼层商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<template #footer>
<el-button @click="showGoodsModal = false">完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Plus, Delete, Check } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const showGoodsModal = ref(false);
// 初始化缺省 options
if (props.options.showFloorTitle === undefined) props.options.showFloorTitle = true;
if (!props.options.floorNum) props.options.floorNum = '1F';
if (!props.options.title) props.options.title = '潮流数码';
if (!props.options.subtitle) props.options.subtitle = 'DIGITAL & TECH';
if (!props.options.subCategories) {
props.options.subCategories = ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
}
if (!props.options.posterImg) {
props.options.posterImg = '/static/banner-floor/floor_banner_1.png';
}
if (!props.options.posterTitle) props.options.posterTitle = '数码潮电 酷玩季';
if (!props.options.posterSub) props.options.posterSub = '领券最高立减 600 元';
if (!props.options.columns) props.options.columns = 4;
if (!props.options.goods) {
props.options.goods = [
{ id: '101', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' }
];
}
const mockPool = [
{ id: '101', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', thumbnail: '/static/goods/goods_8.png' }
];
function handleAddTag() {
if (!Array.isArray(props.options.subCategories)) {
props.options.subCategories = [];
}
props.options.subCategories.push('新分类');
}
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
} else {
props.options.goods.push({ ...item });
}
}
</script>
<style lang="scss" scoped>
.panel-grid-floor {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
.form-row-2 {
display: flex;
gap: 10px;
margin-bottom: 8px;
.flex-1 {
flex: 1;
}
}
}
.tags-editor-wrap {
display: flex;
flex-direction: column;
gap: 6px;
.tag-edit-row {
display: flex;
align-items: center;
gap: 8px;
}
}
.goods-list-preview {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
&.selected {
background: #eff6ff;
border-color: #3b82f6;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #3b82f6;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="panel-hot-zone">
<div class="panel-section">
<div class="section-title">热区底图</div>
<div class="form-item">
<span class="item-label">大图 URL 地址</span>
<el-input v-model="options.imageUrl" size="small" placeholder="请输入热区底图 URL" clearable />
</div>
<div class="hotzone-preview" v-if="options.imageUrl">
<img :src="options.imageUrl" alt="hotzone" class="img-preview" />
</div>
</div>
<div class="panel-section">
<div class="section-title flex-between">
<span>热区列表 ({{ options.zones?.length || 0 }})</span>
<el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区
</el-button>
</div>
<div class="zones-list">
<div
v-for="(zone, idx) in options.zones"
:key="idx"
class="zone-card"
>
<div class="zone-header">
<span>热区 {{ idx + 1 }}</span>
<el-button link type="danger" size="small" @click="options.zones.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="zone-inputs">
<el-input v-model="zone.title" size="small" placeholder="热区标题" />
<el-input v-model="zone.url" size="small" placeholder="跳转路径,如 /goods/1" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Plus, Delete } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
function handleAddZone() {
if (!Array.isArray(props.options.zones)) props.options.zones = [];
props.options.zones.push({
title: `热区 ${props.options.zones.length + 1}`,
url: '/',
top: 10,
left: 10,
width: 20,
height: 20
});
}
</script>
<style lang="scss" scoped>
.panel-hot-zone {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
.item-label {
font-size: 12px;
color: #475569;
}
}
.hotzone-preview {
margin-top: 10px;
max-height: 140px;
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: 6px;
.img-preview {
width: 100%;
object-fit: cover;
}
}
.zones-list {
display: flex;
flex-direction: column;
gap: 8px;
.zone-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
.zone-header {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
}
.zone-inputs {
display: flex;
flex-direction: column;
gap: 6px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="panel-notice-bar">
<!-- 1. 基础配置 -->
<div class="panel-section">
<div class="section-title">快讯标语与更多</div>
<div class="form-item">
<span class="item-label">前缀标语</span>
<el-input v-model="options.title" size="small" placeholder="如:公告快讯 / 今日热点" />
</div>
<div class="form-item">
<span class="item-label">展示更多入口</span>
<el-switch v-model="options.showMore" size="small" />
</div>
<template v-if="options.showMore !== false">
<div class="form-item">
<span class="item-label">更多文案</span>
<el-input v-model="options.moreText" size="small" placeholder="如:更多" />
</div>
<div class="form-item">
<span class="item-label">跳转链接</span>
<el-input v-model="options.moreUrl" size="small" placeholder="如:/article" />
</div>
</template>
</div>
<!-- 2. 快讯条目列表 (匹配万米 Mg 结构化列表) -->
<div class="panel-section">
<div class="section-title flex-between">
<span>快讯条目 ({{ itemsList?.length || 0 }} 条)</span>
<el-button link type="primary" size="small" @click="handleAddItem">
<el-icon><Plus /></el-icon> 添加快讯
</el-button>
</div>
<div class="notice-items-container">
<div
v-for="(item, idx) in itemsList"
:key="idx"
class="notice-card-item"
>
<div class="item-top-row">
<el-select v-model="item.tag" size="small" style="width: 80px">
<el-option label="最新" value="最新" />
<el-option label="活动" value="活动" />
<el-option label="通知" value="通知" />
<el-option label="热议" value="热议" />
</el-select>
<el-input v-model="item.time" size="small" placeholder="时间(如 10-28)" style="width: 100px" />
<div class="item-actions">
<el-button
link
size="small"
:disabled="idx === 0"
@click="moveItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
link
size="small"
:disabled="idx === itemsList.length - 1"
@click="moveItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="itemsList.splice(idx, 1)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="item-title-row">
<el-input v-model="item.title" size="small" placeholder="快讯内容标题" />
</div>
<div class="item-link-row">
<el-input v-model="item.url" size="small" placeholder="跳转路径(如: /article/101)" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Plus, Delete, Top, Bottom } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
if (!props.options.title) props.options.title = '公告快讯';
if (props.options.showMore === undefined) props.options.showMore = true;
if (!props.options.moreText) props.options.moreText = '更多';
if (!props.options.moreUrl) props.options.moreUrl = '/article';
// 统一 items 数据源
if (!props.options.items && !props.options.notices) {
props.options.items = [
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
];
} else if (!props.options.items && props.options.notices) {
props.options.items = props.options.notices.map(n => ({
tag: n.tag || '最新',
title: n.title || n.text || '',
time: n.time || '10-28',
url: n.url || '/article'
}));
}
const itemsList = computed(() => {
return props.options.items;
});
function handleAddItem() {
props.options.items.push({
tag: '最新',
title: '新发布商城系统通知公告',
time: '刚刚',
url: '/article'
});
}
function moveItem(index, step) {
const target = index + step;
if (target < 0 || target >= props.options.items.length) return;
const temp = props.options.items[index];
props.options.items[index] = props.options.items[target];
props.options.items[target] = temp;
}
</script>
<style lang="scss" scoped>
.panel-notice-bar {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.notice-items-container {
display: flex;
flex-direction: column;
gap: 8px;
.notice-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
.item-top-row {
display: flex;
align-items: center;
gap: 6px;
.item-actions {
margin-left: auto;
display: flex;
align-items: center;
}
}
.item-title-row, .item-link-row {
width: 100%;
}
}
}
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="panel-promo-group">
<div class="panel-section">
<div class="section-title">排版列数</div>
<div class="form-item">
<span class="item-label">卡片列数</span>
<el-radio-group v-model="options.columns" size="small">
<el-radio-button :label="2">2 列</el-radio-button>
<el-radio-button :label="3">3 列</el-radio-button>
<el-radio-button :label="4">4 列</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="panel-section">
<div class="section-title flex-between">
<span>活动卡片列表</span>
<el-button link type="primary" size="small" @click="handleAddCard">
<el-icon><Plus /></el-icon> 添加卡片
</el-button>
</div>
<div class="cards-list">
<div
v-for="(card, idx) in options.cards"
:key="idx"
class="promo-card-item"
>
<div class="card-header">
<span>卡片 {{ idx + 1 }}</span>
<el-button link type="danger" size="small" @click="options.cards.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="card-inputs">
<el-input v-model="card.title" size="small" placeholder="主标题(如:今日秒杀)" />
<el-input v-model="card.subtitle" size="small" placeholder="副标题(如:抢神券立减50)" />
<el-input v-model="card.bgImg" size="small" placeholder="卡片底图 URL" />
<el-input v-model="card.url" size="small" placeholder="活动落地页链接" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Plus, Delete } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
function handleAddCard() {
if (!Array.isArray(props.options.cards)) props.options.cards = [];
props.options.cards.push({
title: '大促专场',
subtitle: '限量特惠抢购',
bgImg: '',
url: '/goods'
});
}
</script>
<style lang="scss" scoped>
.panel-promo-group {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
.item-label {
font-size: 12px;
color: #475569;
}
}
.cards-list {
display: flex;
flex-direction: column;
gap: 10px;
.promo-card-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
.card-header {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
}
.card-inputs {
display: flex;
flex-direction: column;
gap: 6px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="panel-recommend-feed">
<!-- 1. 标题设置 -->
<div class="panel-section">
<div class="section-title">标题设置</div>
<div class="form-item">
<span class="item-label">主标题</span>
<el-input v-model="options.title" size="small" placeholder="如:猜你喜欢" />
</div>
<div class="form-item">
<span class="item-label">副标题</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:真实推荐商品流" />
</div>
</div>
<!-- 2. 布局样式 -->
<div class="panel-section">
<div class="section-title">布局样式</div>
<div class="form-item">
<span class="item-label">展现形态</span>
<el-radio-group v-model="options.layoutStyle" size="small">
<el-radio-button label="featured">精选大卡+网格 (推荐)</el-radio-button>
<el-radio-button label="grid">标准推荐网格</el-radio-button>
</el-radio-group>
</div>
<div class="form-item">
<span class="item-label">背景色</span>
<el-color-picker v-model="options.backgroundColor" size="small" />
</div>
</div>
<!-- 3. 加载与数量控制 -->
<div class="panel-section">
<div class="section-title">推荐流加载设置</div>
<div class="form-item">
<span class="item-label">首屏/每页数量</span>
<el-input-number v-model="options.pageSize" :min="4" :max="20" size="small" controls-position="right" />
</div>
<div class="form-item">
<span class="item-label">最多加载页数</span>
<el-input-number v-model="options.maxPages" :min="1" :max="10" size="small" controls-position="right" />
</div>
</div>
<!-- 4. 推荐商品池管理 -->
<div class="panel-section">
<div class="section-title flex-between">
<span>推荐商品池 ({{ options.goods?.length || 0 }} 件)</span>
<el-button type="primary" size="small" @click="showModal = true">
选择真实商品
</el-button>
</div>
<div class="goods-list-preview" v-if="options.goods?.length">
<div v-for="(g, idx) in options.goods" :key="g.id || idx" class="goods-item-row">
<span class="idx-tag">{{ idx === 0 ? '精选' : idx + 1 }}</span>
<img :src="g.thumbnail || defaultThumb" class="g-thumb" alt="" />
<div class="g-info">
<span class="g-name">{{ g.goodsName }}</span>
<span class="g-price">¥{{ g.price }}</span>
</div>
<el-button link type="danger" size="small" @click="options.goods.splice(idx, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 商品选择弹窗 -->
<el-dialog v-model="showModal" title="选择推荐商品" width="600px" append-to-body>
<div class="modal-goods-pool">
<div
v-for="item in mockPool"
:key="item.id"
class="pool-item"
:class="{ selected: isSelected(item.id) }"
@click="toggleSelect(item)"
>
<img :src="item.thumbnail" class="pool-thumb" alt="" />
<div class="pool-meta">
<div class="pool-name">{{ item.goodsName }}</div>
<div class="pool-price">¥{{ item.price }}</div>
</div>
<el-icon v-if="isSelected(item.id)" class="check-icon"><Check /></el-icon>
</div>
</div>
<template #footer>
<el-button @click="showModal = false">完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { Delete, Check } from '@element-plus/icons-vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const showModal = ref(false);
if (!props.options.title) props.options.title = '猜你喜欢';
if (!props.options.subtitle) props.options.subtitle = '真实推荐商品流';
if (!props.options.layoutStyle) props.options.layoutStyle = 'featured';
if (!props.options.backgroundColor) props.options.backgroundColor = '#f8fafc';
if (!props.options.pageSize) props.options.pageSize = 8;
if (!props.options.maxPages) props.options.maxPages = 3;
if (!props.options.goods) {
props.options.goods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' }
];
}
const mockPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', thumbnail: '/static/goods/goods_6.png' }
];
function isSelected(id) {
return props.options.goods?.some(g => String(g.id) === String(id));
}
function toggleSelect(item) {
if (!Array.isArray(props.options.goods)) props.options.goods = [];
const idx = props.options.goods.findIndex(g => String(g.id) === String(item.id));
if (idx > -1) {
props.options.goods.splice(idx, 1);
} else {
props.options.goods.push({ ...item });
}
}
</script>
<style lang="scss" scoped>
.panel-recommend-feed {
padding: 8px 4px;
.panel-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.item-label {
font-size: 12px;
color: #475569;
margin-right: 8px;
white-space: nowrap;
}
}
}
.goods-list-preview {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.goods-item-row {
display: flex;
align-items: center;
gap: 8px;
background: #f8fafc;
padding: 6px 8px;
border-radius: 6px;
.idx-tag {
font-size: 10px;
font-weight: 700;
background: #fee2e2;
color: #dc2626;
padding: 2px 4px;
border-radius: 3px;
}
.g-thumb {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.g-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.g-name {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-price {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
}
}
}
}
.modal-goods-pool {
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
.pool-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #ef4444;
}
&.selected {
background: #fef2f2;
border-color: #ef4444;
}
.pool-thumb {
width: 44px;
height: 44px;
border-radius: 4px;
object-fit: cover;
}
.pool-meta {
flex: 1;
.pool-name {
font-size: 13px;
color: #1e293b;
margin-bottom: 4px;
}
.pool-price {
font-size: 12px;
color: #e11d48;
font-weight: 700;
}
}
.check-icon {
color: #ef4444;
font-size: 18px;
font-weight: 800;
}
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div class="panel-search-header">
<!-- 1. Logo 设置 -->
<div class="panel-section">
<div class="section-title">Logo</div>
<!-- 规格建议提示卡片 -->
<div class="tip-spec-box">
建议尺寸: 220 x 80px, 适配 Logo 可用 440 x 160px; 建议大小: 100KB 内; 建议格式: PNG/WebP
</div>
<!-- 选择与预览区 -->
<div class="logo-choose-row">
<el-button size="small" class="reselect-btn" @click="handleSelectLogo">
{{ options.logoUrl ? '重新选择' : '选择图片' }}
</el-button>
<div class="logo-thumb-wrapper" v-if="options.logoUrl">
<img :src="options.logoUrl" alt="Logo preview" class="logo-preview-thumb" />
<el-icon class="thumb-clear-btn" @click.stop="handleClearLogo" title="清除 Logo"><Close /></el-icon>
</div>
<div class="logo-empty-thumb" v-else>
<span>未选择 Logo</span>
</div>
</div>
<!-- Logo 链接 -->
<div class="form-row mt-12">
<span class="row-label">Logo链接</span>
<div class="link-input-group">
<el-input
v-model="options.logoLink"
size="small"
placeholder="/"
class="link-input"
/>
<el-button size="small" class="link-btn" @click="handleSetLogoLink" title="选择跳转链接">
<el-icon><Link /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 2. 搜索框与功能设置 -->
<div class="panel-section">
<div class="section-title">搜索设置</div>
<div class="form-row">
<span class="row-label">搜索占位</span>
<el-input v-model="options.placeholder" size="small" placeholder="请输入商品名称、品牌或型号" class="flex-1" />
</div>
<div class="form-row mt-10">
<span class="row-label">默认搜索类型</span>
<el-select v-model="options.searchType" size="small" class="flex-1">
<el-option label="商品搜索" value="goods" />
<el-option label="店铺搜索" value="shop" />
</el-select>
</div>
<div class="form-row mt-10 flex-between">
<span class="row-label">重置搜索类型</span>
<el-switch v-model="options.resetSearchType" size="small" />
</div>
<div class="form-row mt-10 flex-between">
<span class="row-label">显示右侧购物车</span>
<el-switch v-model="options.showCart" size="small" />
</div>
<div class="form-row mt-10 flex-between">
<span class="row-label">显示热门推荐词</span>
<el-switch v-model="options.showHotWords" size="small" />
</div>
</div>
<!-- 4. 购物车样式设置 -->
<div class="panel-section" v-if="options.showCart !== false">
<div class="section-title">购物车设置</div>
<div class="form-row">
<span class="row-label">购物车文案</span>
<el-input v-model="options.cartText" size="small" placeholder="购物车" class="flex-1" />
</div>
<div class="form-row mt-10">
<span class="row-label">购物车背景色</span>
<div class="color-picker-row">
<el-color-picker v-model="options.cartBgColor" size="small" />
<el-input v-model="options.cartBgColor" size="small" placeholder="#ffffff" class="color-hex-input" />
</div>
</div>
<div class="form-row mt-10">
<span class="row-label">购物车边框色</span>
<div class="color-picker-row">
<el-color-picker v-model="options.cartBorderColor" size="small" />
<el-input v-model="options.cartBorderColor" size="small" placeholder="#e2e8f0" class="color-hex-input" />
</div>
</div>
<div class="form-row mt-10">
<span class="row-label">购物车文字色</span>
<div class="color-picker-row">
<el-color-picker v-model="options.cartTextColor" size="small" />
<el-input v-model="options.cartTextColor" size="small" placeholder="#111827" class="color-hex-input" />
</div>
</div>
<div class="form-row mt-10">
<span class="row-label">购物车数量色</span>
<div class="color-picker-row">
<el-color-picker v-model="options.cartBadgeColor" size="small" />
<el-input v-model="options.cartBadgeColor" size="small" placeholder="#e05644" class="color-hex-input" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { Plus, Close, Link, Top, Bottom, Delete } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
if (props.options.logoLink === undefined) props.options.logoLink = '/';
if (props.options.placeholder === undefined) props.options.placeholder = '请输入商品名称、品牌或型号';
if (props.options.searchType === undefined) props.options.searchType = 'goods';
if (props.options.resetSearchType === undefined) props.options.resetSearchType = false;
if (props.options.showCart === undefined) props.options.showCart = true;
if (props.options.showHotWords === undefined) props.options.showHotWords = true;
if (props.options.cartText === undefined) props.options.cartText = '购物车';
if (props.options.cartBgColor === undefined) props.options.cartBgColor = '#ffffff';
if (props.options.cartBorderColor === undefined) props.options.cartBorderColor = '#e2e8f0';
if (props.options.cartTextColor === undefined) props.options.cartTextColor = '#111827';
if (props.options.cartBadgeColor === undefined) props.options.cartBadgeColor = '#e05644';
if (!Array.isArray(props.options.hotWords)) {
props.options.hotWords = ['工业品', '进口好物', '综合特惠', '企业采购'];
}
const openImagePicker = inject('openImagePicker', null);
const openLinkPicker = inject('openLinkPicker', null);
function handleSelectLogo() {
if (openImagePicker) {
openImagePicker((url) => {
if (url) {
props.options.logoUrl = url;
}
});
} else {
ElMessageBox.prompt('请输入 Logo 图片 URL 地址:', '选择 Logo 图片', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.logoUrl || '',
inputPattern: /^\S+$/,
inputErrorMessage: '图片地址不能为空'
})
.then(({ value }) => {
props.options.logoUrl = value;
})
.catch(() => {});
}
}
function handleClearLogo() {
props.options.logoUrl = '';
}
function handleSetLogoLink() {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
props.options.logoLink = url;
});
} else {
ElMessageBox.prompt('请输入跳转链接路径:', '设置 Logo 链接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.options.logoLink || '/',
inputPattern: /^\S+$/,
inputErrorMessage: '链接路径不能为空'
})
.then(({ value }) => {
props.options.logoLink = value;
})
.catch(() => {});
}
}
function handleAddHotWord() {
props.options.hotWords.push('新品推荐');
}
function handleRemoveHotWord(idx) {
props.options.hotWords.splice(idx, 1);
}
function moveHotWord(idx, offset) {
const target = idx + offset;
if (target < 0 || target >= props.options.hotWords.length) return;
const item = props.options.hotWords.splice(idx, 1)[0];
props.options.hotWords.splice(target, 0, item);
}
</script>
<style lang="scss" scoped>
.panel-search-header {
padding: 12px;
box-sizing: border-box;
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #475569;
&.flex-between {
justify-content: space-between;
.row-label {
width: auto;
white-space: nowrap;
}
}
.row-label {
width: 90px;
flex-shrink: 0;
color: #475569;
font-size: 12px;
white-space: nowrap;
}
.flex-1 {
flex: 1;
}
}
.mt-10 {
margin-top: 10px;
}
.mt-12 {
margin-top: 12px;
}
/* 建议规格暗色卡片 */
.tip-spec-box {
background: #2b2e3b;
color: #f1f5f9;
padding: 8px 10px;
border-radius: 4px;
font-size: 11px;
line-height: 1.5;
margin-bottom: 10px;
word-break: break-all;
}
/* Logo 选择行 */
.logo-choose-row {
display: flex;
align-items: center;
gap: 12px;
.reselect-btn {
width: 88px;
flex-shrink: 0;
background: #f1f5f9;
color: #334155;
border: 1px solid #cbd5e1;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
.logo-thumb-wrapper {
flex: 1;
height: 48px;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 4px 8px;
box-sizing: border-box;
.logo-preview-thumb {
max-height: 40px;
max-width: 100%;
object-fit: contain;
}
.thumb-clear-btn {
position: absolute;
top: 4px;
right: 4px;
font-size: 14px;
color: #94a3b8;
cursor: pointer;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
&:hover {
color: #ef4444;
}
}
}
.logo-empty-thumb {
flex: 1;
height: 48px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 12px;
}
}
/* Logo 链接输入区 */
.link-input-group {
flex: 1;
display: flex;
align-items: center;
border: 1px solid #cbd5e1;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
.link-input {
flex: 1;
:deep(.el-input__wrapper) {
box-shadow: none !important;
border-radius: 0;
}
}
.link-btn {
border: none;
border-left: 1px solid #e2e8f0;
border-radius: 0;
background: #f8fafc;
color: #64748b;
padding: 0 10px;
&:hover {
background: #f1f5f9;
color: #ff6a00;
}
}
}
/* 搜索轮播列表 */
.hot-words-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
.hot-word-item {
display: flex;
align-items: center;
gap: 6px;
background: #f8fafc;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid #e2e8f0;
.word-edit-input {
flex: 1;
}
.word-actions {
display: flex;
align-items: center;
gap: 2px;
}
}
}
.empty-tip-text {
color: #94a3b8;
font-size: 12px;
padding: 8px 0;
text-align: center;
}
/* 颜色选择器行 */
.color-picker-row {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
.color-hex-input {
flex: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="panel-title-bar">
<div class="panel-section">
<div class="section-title">标题内容设置</div>
<div class="form-item">
<span class="item-label">主标题文案</span>
<el-input v-model="options.mainTitle" size="small" placeholder="如:限时特卖" />
</div>
<div class="form-item">
<span class="item-label">副标题文字</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:精选爆款 每日早十点上新" />
</div>
<div class="form-item flex-row">
<span class="item-label">对齐方式</span>
<el-radio-group v-model="options.align" size="small">
<el-radio-button label="left">靠左对齐</el-radio-button>
<el-radio-button label="center">居中对齐</el-radio-button>
</el-radio-group>
</div>
<div class="form-item flex-row">
<span class="item-label">显示右侧“查看更多”</span>
<el-switch v-model="options.showMore" size="small" />
</div>
<div class="form-item" v-if="options.showMore">
<span class="item-label">更多链接文案</span>
<el-input v-model="options.moreText" size="small" placeholder="查看更多 >" />
</div>
<div class="form-item" v-if="options.showMore">
<span class="item-label">更多跳转地址</span>
<el-input v-model="options.moreUrl" size="small" placeholder="如 /goods" />
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
</script>
<style lang="scss" scoped>
.panel-title-bar {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
}
}
}
}
</style>

View File

@@ -0,0 +1,514 @@
<template>
<div class="panel-top-bar">
<!-- 1. 顶部栏内容卡片 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandContent = !expandContent">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandContent }"><ArrowRight /></el-icon>
<span class="header-title">顶部栏内容</span>
<el-tooltip content="设置顶部栏快捷入口和右侧快捷菜单" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
<span class="header-subtitle">顶部栏快捷入口和右侧快捷菜单</span>
</div>
</div>
<div class="collapse-body" v-show="expandContent">
<!-- 左侧登录区 -->
<div class="section-block">
<div class="block-title">左侧登录区</div>
<div class="form-row">
<span class="row-label">欢迎文案</span>
<el-input
v-model="localOptions.welcomeText"
size="small"
placeholder="欢迎来到商城"
/>
</div>
<div class="form-row">
<span class="row-label">登录文案</span>
<div class="input-with-action">
<el-input
v-model="localOptions.loginText"
size="small"
placeholder="登录"
/>
<el-button
size="small"
class="action-btn"
@click="handleSetLink('loginUrl', '设置登录跳转链接', localOptions.loginUrl || '/login')"
title="设置登录链接"
>
<el-icon><Link /></el-icon>
</el-button>
</div>
</div>
<div class="form-row">
<span class="row-label">注册文案</span>
<div class="input-with-action">
<el-input
v-model="localOptions.registerText"
size="small"
placeholder="免费注册"
/>
<el-button
size="small"
class="action-btn"
@click="handleSetLink('registerUrl', '设置注册跳转链接', localOptions.registerUrl || '/signUp')"
title="设置注册链接"
>
<el-icon><Link /></el-icon>
</el-button>
</div>
</div>
<div class="form-row">
<span class="row-label">用户中心链接</span>
<div
class="link-box-btn"
@click="handleSetLink('userCenterUrl', '设置用户中心跳转链接', localOptions.userCenterUrl || '/order-list')"
>
<el-icon><Link /></el-icon>
<span class="link-box-text">自定义链接 · {{ localOptions.userCenterUrl || '/order-list' }}</span>
</div>
</div>
<div class="form-row">
<span class="row-label">退出文案</span>
<el-input
v-model="localOptions.logoutText"
size="small"
placeholder="退出"
/>
</div>
</div>
<!-- 右侧快捷菜单 -->
<div class="section-block mt-16">
<div class="block-title">右侧快捷菜单</div>
<div class="menu-list">
<div
v-for="(item, idx) in localOptions.rightMenus"
:key="idx"
class="menu-row"
>
<el-input
v-model="item.title"
size="small"
class="menu-input"
placeholder="菜单名称"
/>
<el-button
size="small"
class="action-btn"
@click="handleSetMenuItemLink(item)"
title="设置链接"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === 0"
@click="moveMenuItem(idx, -1)"
title="上移"
>
<el-icon><Top /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn"
:disabled="idx === localOptions.rightMenus.length - 1"
@click="moveMenuItem(idx, 1)"
title="下移"
>
<el-icon><Bottom /></el-icon>
</el-button>
<el-button
size="small"
class="action-btn del-btn"
@click="deleteMenuItem(idx)"
title="删除"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<el-button
class="add-menu-btn"
size="small"
@click="addMenuItem"
>
+ 添加快捷入口
</el-button>
</div>
</div>
</div>
<!-- 2. 样式卡片 -->
<div class="setting-collapse-card">
<div class="collapse-header" @click="expandStyle = !expandStyle">
<div class="header-left">
<el-icon class="arrow-icon" :class="{ 'is-expanded': expandStyle }"><ArrowRight /></el-icon>
<span class="header-title">样式</span>
</div>
</div>
<div class="collapse-body" v-show="expandStyle">
<div class="form-row">
<span class="row-label">文字颜色</span>
<div class="color-picker-row">
<el-color-picker
v-model="localOptions.textColor"
size="small"
class="color-picker-swatch"
/>
<el-input
v-model="localOptions.textColor"
size="small"
class="color-input"
placeholder="#4b5563"
/>
</div>
</div>
<div class="form-row mt-12">
<span class="row-label">字号</span>
<el-input
v-model="localOptions.fontSize"
size="small"
placeholder="12"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { ArrowRight, QuestionFilled, Link, Top, Bottom, Delete } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
const props = defineProps({
component: {
type: Object,
default: null
},
options: {
type: Object,
default: () => ({})
}
});
const expandContent = ref(true);
const expandStyle = ref(true);
const localOptions = computed(() => {
if (!props.options) return {};
if (props.options.welcomeText === undefined) props.options.welcomeText = '欢迎来到商城';
if (props.options.loginText === undefined) props.options.loginText = '登录';
if (props.options.loginUrl === undefined) props.options.loginUrl = '/login';
if (props.options.registerText === undefined) props.options.registerText = '免费注册';
if (props.options.registerUrl === undefined) props.options.registerUrl = '/signUp';
if (props.options.userCenterUrl === undefined) props.options.userCenterUrl = '/order-list';
if (props.options.logoutText === undefined) props.options.logoutText = '退出';
if (!Array.isArray(props.options.rightMenus)) {
props.options.rightMenus = [
{ title: '基本信息', url: '/home/UserInfo' },
{ title: '我的订单', url: '/home/MyOrder' },
{ title: '账户安全', url: '/home/AccountSafe' },
{ title: '消息', url: '/home/Msg' }
];
}
if (!props.options.textColor) props.options.textColor = '#4b5563';
if (!props.options.fontSize) props.options.fontSize = 12;
return props.options;
});
const openLinkPicker = inject('openLinkPicker', null);
function handleSetLink(field, title, currentVal) {
if (openLinkPicker) {
openLinkPicker(({ url }) => {
localOptions.value[field] = url;
});
} else {
ElMessageBox.prompt('请输入跳转链接路径:', title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: currentVal || '/',
inputPattern: /^\S+$/,
inputErrorMessage: '链接路径不能为空'
})
.then(({ value }) => {
localOptions.value[field] = value;
})
.catch(() => {});
}
}
function handleSetMenuItemLink(item) {
if (openLinkPicker) {
openLinkPicker(({ url, title }) => {
item.url = url;
if (!item.title && title) item.title = title;
});
} else {
ElMessageBox.prompt('请输入菜单跳转链接:', `设置 [${item.title || '快捷菜单'}] 链接`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: item.url || '/',
inputPattern: /^\S+$/,
inputErrorMessage: '链接路径不能为空'
})
.then(({ value }) => {
item.url = value;
})
.catch(() => {});
}
}
function moveMenuItem(idx, offset) {
const target = idx + offset;
const list = localOptions.value.rightMenus;
if (target < 0 || target >= list.length) return;
const temp = list[idx];
list[idx] = list[target];
list[target] = temp;
}
function deleteMenuItem(idx) {
localOptions.value.rightMenus.splice(idx, 1);
}
function addMenuItem() {
localOptions.value.rightMenus.push({
title: '新快捷入口',
url: '/'
});
}
</script>
<style lang="scss" scoped>
.panel-top-bar {
padding: 12px;
box-sizing: border-box;
.setting-collapse-card {
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
.collapse-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
cursor: pointer;
user-select: none;
background: #fafafa;
border-bottom: 1px solid transparent;
transition: background-color 0.2s;
&:hover {
background: #f5f7fa;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
.arrow-icon {
font-size: 12px;
color: #909399;
transition: transform 0.2s;
&.is-expanded {
transform: rotate(90deg);
}
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #262626;
white-space: nowrap;
}
.help-icon {
font-size: 14px;
color: #909399;
cursor: pointer;
}
.header-subtitle {
font-size: 11px;
color: #909399;
margin-left: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.collapse-body {
padding: 14px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
.section-block {
.block-title {
font-size: 13px;
font-weight: 700;
color: #1f2937;
margin-bottom: 12px;
}
&.mt-16 {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e5e7eb;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.mt-12 {
margin-top: 12px;
}
.row-label {
font-size: 12px;
color: #4b5563;
}
.input-with-action {
display: flex;
align-items: center;
gap: 6px;
.action-btn {
padding: 0 8px;
height: 28px;
color: #4b5563;
border-color: #d1d5db;
}
}
.link-box-btn {
width: 100%;
height: 32px;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
color: #374151;
font-size: 12px;
transition: all 0.2s;
&:hover {
border-color: #ff6a00;
color: #ff6a00;
}
.link-box-text {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.color-picker-row {
display: flex;
align-items: center;
gap: 8px;
.color-input {
flex: 1;
}
}
}
/* 菜单列表 */
.menu-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
.menu-row {
display: flex;
align-items: center;
gap: 6px;
.menu-input {
flex: 1;
}
.action-btn {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-color: #e5e7eb;
color: #6b7280;
&.del-btn {
border-color: #fca5a5;
color: #ef4444;
&:hover {
background: #fef2f2;
border-color: #ef4444;
}
}
&:disabled {
color: #d1d5db;
border-color: #f3f4f6;
}
}
}
}
.add-menu-btn {
width: 100%;
height: 32px;
border: 1px dashed #d1d5db;
border-radius: 4px;
background: #ffffff;
color: #374151;
font-size: 12px;
transition: all 0.2s;
&:hover {
color: #ff6a00;
border-color: #ff6a00;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="panel-user-benefits">
<!-- 用户问候与标语 -->
<div class="panel-section">
<div class="section-title">问候信息</div>
<div class="form-item">
<span class="item-label">状态标签</span>
<el-input v-model="options.badgeText" size="small" placeholder="如:请登录" clearable />
</div>
<div class="form-item mt-10">
<span class="item-label">欢迎主标语</span>
<el-input v-model="options.welcomeText" size="small" placeholder="如:欢迎来到商城" clearable />
</div>
<div class="form-item mt-10">
<span class="item-label">副标语提示</span>
<el-input v-model="options.subtitle" size="small" placeholder="如:登录后查看专属权益" clearable />
</div>
<div class="form-item mt-10">
<span class="item-label">自定义头像 URL (留空展示默认头像)</span>
<el-input v-model="options.avatar" size="small" placeholder="请输入头像图片地址" clearable />
</div>
</div>
<!-- 登录与注册按钮 -->
<div class="panel-section">
<div class="section-title">按钮配置</div>
<div class="form-item">
<span class="item-label">登录按钮文案</span>
<el-input v-model="options.loginText" size="small" placeholder="登录" />
</div>
<div class="form-item mt-10">
<span class="item-label">登录跳转路径</span>
<el-input v-model="options.loginUrl" size="small" placeholder="/login" />
</div>
<div class="form-item mt-10">
<span class="item-label">注册按钮文案</span>
<el-input v-model="options.registerText" size="small" placeholder="注册" />
</div>
<div class="form-item mt-10">
<span class="item-label">注册跳转路径</span>
<el-input v-model="options.registerUrl" size="small" placeholder="/signUp" />
</div>
</div>
<!-- 3项指标节点 (购物车 / 优惠券 / 订单) -->
<div class="panel-section">
<div class="section-title">指标节点展示</div>
<!-- 购物车 -->
<div class="metric-config-card">
<div class="metric-config-head">
<span class="metric-title">购物车</span>
<el-switch v-model="options.showCart" size="small" />
</div>
<div class="metric-inputs" v-if="options.showCart !== false">
<div class="sub-field">
<span class="sub-label">展示文案</span>
<el-input v-model="options.cartText" size="small" placeholder="购物车" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.cartUrl" size="small" placeholder="/cart" />
</div>
</div>
</div>
<!-- 优惠券 -->
<div class="metric-config-card mt-10">
<div class="metric-config-head">
<span class="metric-title">优惠券</span>
<el-switch v-model="options.showCoupons" size="small" />
</div>
<div class="metric-inputs" v-if="options.showCoupons !== false">
<div class="sub-field">
<span class="sub-label">展示文案</span>
<el-input v-model="options.couponText" size="small" placeholder="优惠券" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.couponUrl" size="small" placeholder="/member/coupon" />
</div>
</div>
</div>
<!-- 订单 -->
<div class="metric-config-card mt-10">
<div class="metric-config-head">
<span class="metric-title">订单</span>
<el-switch v-model="options.showOrders" size="small" />
</div>
<div class="metric-inputs" v-if="options.showOrders !== false">
<div class="sub-field">
<span class="sub-label">展示文案</span>
<el-input v-model="options.orderText" size="small" placeholder="订单" />
</div>
<div class="sub-field mt-6">
<span class="sub-label">跳转路径</span>
<el-input v-model="options.orderUrl" size="small" placeholder="/member/order" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
// 初始化缺省配置
if (props.options.badgeText === undefined) props.options.badgeText = '请登录';
if (props.options.welcomeText === undefined) props.options.welcomeText = '欢迎来到商城';
if (props.options.subtitle === undefined) props.options.subtitle = '登录后查看专属权益';
if (props.options.loginText === undefined) props.options.loginText = '登录';
if (props.options.registerText === undefined) props.options.registerText = '注册';
if (props.options.loginUrl === undefined) props.options.loginUrl = '/login';
if (props.options.registerUrl === undefined) props.options.registerUrl = '/signUp';
if (props.options.showCart === undefined) props.options.showCart = true;
if (props.options.showCoupons === undefined) props.options.showCoupons = true;
if (props.options.showOrders === undefined) props.options.showOrders = true;
if (props.options.cartText === undefined) props.options.cartText = '购物车';
if (props.options.couponText === undefined) props.options.couponText = '优惠券';
if (props.options.orderText === undefined) props.options.orderText = '订单';
if (props.options.cartUrl === undefined) props.options.cartUrl = '/cart';
if (props.options.couponUrl === undefined) props.options.couponUrl = '/member/coupon';
if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/order';
</script>
<style lang="scss" scoped>
.panel-user-benefits {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
.item-label {
font-size: 12px;
color: #475569;
}
}
.metric-config-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 12px;
.metric-config-head {
display: flex;
justify-content: space-between;
align-items: center;
.metric-title {
font-size: 13px;
font-weight: 600;
color: #334155;
}
}
.metric-inputs {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
.sub-field {
display: flex;
align-items: center;
gap: 8px;
.sub-label {
width: 56px;
font-size: 12px;
color: #64748b;
flex-shrink: 0;
}
}
}
}
}
.mt-6 { margin-top: 6px; }
.mt-10 { margin-top: 10px; }
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="panel-video">
<div class="panel-section">
<div class="section-title">视频播放设置</div>
<div class="form-item">
<span class="item-label">视频文件 URL (.mp4)</span>
<el-input v-model="options.videoUrl" size="small" placeholder="请输入视频在线地址" clearable />
</div>
<div class="form-item">
<span class="item-label">视频封面图片 URL</span>
<el-input v-model="options.coverUrl" size="small" placeholder="请输入视频封面图地址" clearable />
</div>
<div class="form-item flex-row">
<span class="item-label">播放高度 (px)</span>
<el-input-number v-model="options.height" :min="200" :max="800" size="small" controls-position="right" />
</div>
<div class="form-item flex-row">
<span class="item-label">自动静音播放</span>
<el-switch v-model="options.autoPlay" size="small" />
</div>
<div class="form-item flex-row">
<span class="item-label">循环播放</span>
<el-switch v-model="options.loop" size="small" />
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
component: {
type: Object,
required: true
},
options: {
type: Object,
required: true
}
});
</script>
<style lang="scss" scoped>
.panel-video {
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
&.flex-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.item-label {
font-size: 12px;
color: #475569;
}
}
}
}
</style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
<template>
<div class="tpl-article" :style="customStyle">
<!-- 文章顶部标题区 -->
<div class="article-header" v-if="options.showTitle !== false && options.title">
<h2 class="article-main-title">{{ options.title }}</h2>
<p class="article-sub-title" v-if="options.subtitle">{{ options.subtitle }}</p>
<div class="article-meta-bar" v-if="options.showMeta !== false">
<span class="meta-tag">{{ options.tag || '品牌专栏' }}</span>
<span class="meta-item author" v-if="options.author">作者:{{ options.author }}</span>
<span class="meta-item date" v-if="options.date">发布于 {{ options.date }}</span>
<span class="meta-item views">阅读 2.4k+</span>
</div>
</div>
<!-- 文章正文排版区 (Rich Text Article Body) -->
<div
class="article-body-typography"
:class="[`align-${options.textAlign || 'left'}`]"
:style="{ fontSize: `${options.fontSize || 15}px` }"
v-html="renderedContent"
></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultArticleHtml = `
<h3>关于我们 · 甄选美好生活</h3>
<p>我们始终坚信,一件好的产品不仅能改善日常生活的品质,更传递着对美好生活的执着追求。自创立以来,我们严选全球顶尖供应链,深耕每一道生产工艺与质检环节,力求将兼具美学价值与极致实用性的商品送达每一位用户手中。</p>
<blockquote>品质是我们的信仰,体验是我们的底线。从选材到交付,每一步都经得起时光检验。</blockquote>
<h4>四大核心服务保障</h4>
<ul>
<li><strong>100% 官方正品</strong>:源头品牌直采,全链路防伪溯源,假一赔十保障。</li>
<li><strong>极速顺丰冷链</strong>:全国多仓联动发货,核心城市支持当日达/次日达。</li>
<li><strong>7天无理由退换</strong>:专业客服团队 7×24 小时贴心守护,售后无忧。</li>
<li><strong>会员专享权益</strong>:尊享积分抵现、生日特权礼及专属品鉴沙龙。</li>
</ul>
<p>感谢一路相伴的每一位朋友,未来我们将持续探索更多优质生活场景,与您共同见证品质生活的美好绽放。</p>
`.trim();
const renderedContent = computed(() => {
return props.options?.content || defaultArticleHtml;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '24px 28px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-article {
width: 100%;
box-sizing: border-box;
user-select: none;
.article-header {
padding-bottom: 16px;
margin-bottom: 18px;
border-bottom: 1px solid #f1f5f9;
.article-main-title {
font-size: 24px;
font-weight: 800;
color: #0f172a;
line-height: 1.35;
margin: 0 0 8px 0;
letter-spacing: -0.3px;
}
.article-sub-title {
font-size: 14px;
color: #64748b;
line-height: 1.5;
margin: 0 0 12px 0;
}
.article-meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #94a3b8;
.meta-tag {
font-size: 11px;
font-weight: 600;
background: #eff6ff;
color: #2563eb;
padding: 2px 8px;
border-radius: 4px;
}
.meta-item {
display: inline-flex;
align-items: center;
}
}
}
/* 文章排版样式系统 (Editorial Typography) */
.article-body-typography {
color: #334155;
line-height: 1.8;
letter-spacing: 0.2px;
&.align-center {
text-align: center;
}
&.align-justify {
text-align: justify;
}
:deep(h2), :deep(h3), :deep(h4) {
color: #0f172a;
font-weight: 700;
line-height: 1.4;
margin-top: 20px;
margin-bottom: 10px;
}
:deep(h2) {
font-size: 20px;
border-left: 4px solid #2563eb;
padding-left: 10px;
}
:deep(h3) {
font-size: 18px;
color: #1e293b;
}
:deep(h4) {
font-size: 16px;
}
:deep(p) {
margin: 0 0 14px 0;
line-height: 1.8;
}
:deep(blockquote) {
margin: 16px 0;
padding: 12px 18px;
background: #f8fafc;
border-left: 4px solid #3b82f6;
border-radius: 0 8px 8px 0;
color: #475569;
font-style: italic;
line-height: 1.7;
}
:deep(ul), :deep(ol) {
margin: 12px 0 16px 20px;
padding: 0;
li {
margin-bottom: 6px;
line-height: 1.7;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
margin: 16px auto;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
:deep(strong) {
color: #0f172a;
font-weight: 700;
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,312 @@
<template>
<div
class="tpl-carousel"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
>
<div class="carousel-container">
<!-- 轮播图主屏 -->
<transition name="carousel-fade" mode="out-in">
<div :key="currentIdx" class="slide-view">
<img
:src="currentSlide.img"
:alt="currentSlide.title || 'banner'"
class="slide-img"
/>
</div>
</transition>
<!-- 左右切换箭头按钮 -->
<template v-if="options.showArrow !== false && slidesList.length > 1">
<div class="arrow-btn prev-btn" @click.stop="prevSlide">
<el-icon><ArrowLeft /></el-icon>
</div>
<div class="arrow-btn next-btn" @click.stop="nextSlide">
<el-icon><ArrowRight /></el-icon>
</div>
</template>
<!-- 底部指示点 -->
<div
v-if="options.showIndicator !== false && slidesList.length > 1"
class="carousel-indicators"
>
<span
v-for="(_, idx) in slidesList"
:key="idx"
class="indicator-dot"
:class="{ active: currentIdx === idx }"
@click.stop="currentIdx = idx"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const currentIdx = ref(0);
let timer = null;
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
const slidesList = computed(() => {
if (Array.isArray(props.options.list) && props.options.list.length > 0) {
return props.options.list;
}
return [
{ title: '出游囤好货', img: '/static/carousel/banner_1.png', url: '/goodsList?seckill=1' },
{ title: '科技新品', img: '/static/carousel/banner_2.png', url: '/goodsList?category=1' },
{ title: '原产地直采', img: '/static/carousel/banner_3.png', url: '/goodsList?category=2' },
{ title: '大牌美妆', img: '/static/carousel/banner_4.png', url: '/goodsList?category=3' }
];
});
const currentSlide = computed(() => {
return slidesList.value[currentIdx.value] || slidesList.value[0];
});
function getAspectRatioCss(ratio) {
if (ratio === '16:9') return '16 / 9';
if (ratio === '2:1') return '2 / 1';
if (ratio === '3:1') return '3 / 1';
if (ratio === 'custom') return null;
return '2.5 / 1';
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const fit = props.options.fitMode || 'cover';
const styleObj = {
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0px',
'--carousel-fit': fit
};
if (props.options.height) {
styleObj.height = `${props.options.height}px`;
styleObj.aspectRatio = 'auto';
} else {
const ratio = props.options.aspectRatio || '2.5:1';
const ratioCss = getAspectRatioCss(ratio);
if (ratioCss) {
styleObj.aspectRatio = ratioCss;
styleObj['--carousel-ratio'] = ratioCss;
} else {
styleObj.height = `${s.height || 420}px`;
styleObj.aspectRatio = 'auto';
}
}
return styleObj;
});
function prevSlide() {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value - 1 + len) % len;
resetTimer();
}
function nextSlide() {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value + 1) % len;
resetTimer();
}
function startTimer() {
stopTimer();
if (props.options.autoPlay !== false && slidesList.value.length > 1) {
const interval = Number(props.options.interval) || 4000;
timer = setInterval(() => {
const len = slidesList.value.length;
currentIdx.value = (currentIdx.value + 1) % len;
}, interval);
}
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function resetTimer() {
startTimer();
}
watch(
() => [props.options.autoPlay, props.options.interval, slidesList.value.length],
() => {
startTimer();
}
);
onMounted(() => {
startTimer();
});
onBeforeUnmount(() => {
stopTimer();
});
</script>
<style lang="scss" scoped>
.tpl-carousel {
width: 100%;
position: relative;
overflow: hidden;
user-select: none;
background: #f8fafc;
box-sizing: border-box;
.carousel-container {
width: 100%;
height: 100%;
position: relative;
.slide-view {
width: 100%;
height: 100%;
.slide-img {
width: 100%;
height: 100%;
object-fit: var(--carousel-fit, contain);
display: block;
}
}
// 切换动效
.carousel-fade-enter-active,
.carousel-fade-leave-active {
transition: opacity 0.4s ease;
}
.carousel-fade-enter-from,
.carousel-fade-leave-to {
opacity: 0;
}
// 左右箭头按钮
.arrow-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.35);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s ease;
z-index: 10;
backdrop-filter: blur(2px);
&:hover {
background: rgba(15, 23, 42, 0.65);
transform: translateY(-50%) scale(1.08);
}
&.prev-btn {
left: 16px;
}
&.next-btn {
right: 16px;
}
}
// 底部指示点
.carousel-indicators {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.65);
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
&.active {
width: 22px;
border-radius: 5px;
background: #ea580c;
}
&:hover:not(.active) {
background: #ffffff;
}
}
}
}
&.is-compact {
.arrow-btn {
width: 26px;
height: 26px;
font-size: 12px;
&.prev-btn { left: 8px; }
&.next-btn { right: 8px; }
}
.carousel-indicators {
bottom: 8px;
gap: 5px;
.indicator-dot {
width: 6px;
height: 6px;
&.active { width: 14px; }
}
}
}
&.is-mini {
.arrow-btn {
width: 20px;
height: 20px;
font-size: 10px;
&.prev-btn { left: 4px; }
&.next-btn { right: 4px; }
}
.carousel-indicators {
bottom: 4px;
gap: 3px;
.indicator-dot {
width: 4px;
height: 4px;
&.active { width: 10px; }
}
}
}
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div class="tpl-category-mega-menu" :style="customStyle">
<!-- 顶部标题:分类(同万米设计,纯净大方) -->
<div class="menu-header">
<span class="header-title">{{ options.title || '分类' }}</span>
</div>
<!-- 垂直一级类目列表(8项均匀铺满整个高度) -->
<div class="category-list">
<div
v-for="(cat, idx) in categoriesList"
:key="idx"
class="cat-item"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#ff6000',
'--font-size': `${options.fontSize || 13}px`
}"
>
<span class="cat-name">{{ cat.name }}</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ArrowRight } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultCategories = [
{ id: '1', name: '潮流数码' },
{ id: '2', name: '奢宠美妆' },
{ id: '3', name: '智能家电' },
{ id: '4', name: '服饰鞋包' },
{ id: '5', name: '运动潮流' },
{ id: '6', name: '精品生鲜' },
{ id: '7', name: '居家生活' },
{ id: '8', name: '母婴玩具' }
];
const categoriesList = computed(() => {
if (Array.isArray(props.options?.categories) && props.options.categories.length) {
return props.options.categories.map(c => {
const rawName = c.name || c.title || '';
const cleanName = rawName.includes('/') ? rawName.split('/')[0].trim() : rawName;
return {
...c,
name: cleanName
};
});
}
return defaultCategories;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-category-mega-menu {
width: 100%;
height: 100%;
min-height: 420px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
user-select: none;
overflow: hidden;
.menu-header {
padding: 14px 16px 6px;
display: flex;
align-items: center;
.header-title {
font-size: 15px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.5px;
}
}
.category-list {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 2px 0 10px;
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
cursor: pointer;
transition: all 0.15s ease;
.cat-name {
font-size: var(--font-size, 13px);
color: var(--text-color, #334155);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arrow-icon {
font-size: 12px;
color: #94a3b8;
transition: transform 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
&:hover {
background: #fff7ed;
.cat-name {
color: var(--active-color, #ff6000);
font-weight: 600;
}
.arrow-icon {
color: var(--active-color, #ff6000);
transform: translateX(2px);
}
}
}
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
<template>
<div class="tpl-coupon" :class="`skin-${options.skin || 'red'}`" :style="customStyle">
<div
class="coupon-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div v-for="(cp, idx) in couponList" :key="cp.id || idx" class="coupon-item">
<div class="left-val">
<span class="symbol">¥</span>
<span class="val">{{ cp.price }}</span>
</div>
<div class="right-info">
<span class="title">满{{ cp.consumeThreshold }}可用</span>
<span class="scope" v-if="cp.title">{{ cp.title }}</span>
<span class="btn">立即领取</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultCoupons = [
{ id: '1', price: 20, consumeThreshold: 199, title: '全品类通用券', limitText: '全场通用 无门槛限制' },
{ id: '2', price: 50, consumeThreshold: 399, title: '数码家电专享券', limitText: '数码/手机/家电可用' },
{ id: '3', price: 100, consumeThreshold: 699, title: '大牌美妆特惠券', limitText: '美妆奢护专区可用' },
{ id: '4', price: 10, consumeThreshold: 99, title: '新客首单立减券', limitText: '首次下单专享福利' }
];
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 4;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 3);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
const couponList = computed(() => {
if (Array.isArray(props.options?.coupons) && props.options.coupons.length > 0) {
return props.options.coupons;
}
const count = props.options?.columns || 4;
return defaultCoupons.slice(0, count);
});
</script>
<style lang="scss" scoped>
.tpl-coupon {
width: 100%;
user-select: none;
box-sizing: border-box;
.coupon-grid {
display: grid;
gap: 12px;
.coupon-item {
display: flex;
height: 72px;
border-radius: 6px;
overflow: hidden;
.left-val {
width: 80px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
.symbol { font-size: 13px; }
.val { font-size: 22px; }
}
.right-info {
flex: 1;
background: #ffffff;
padding: 6px 10px;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid #f1f5f9;
border-left: none;
.title {
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
.scope {
font-size: 11px;
color: #64748b;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.btn {
font-size: 11px;
color: #e11d48;
cursor: pointer;
font-weight: 600;
margin-top: 3px;
}
}
}
}
&.skin-red {
.left-val { background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%); }
.btn { color: #e11d48 !important; }
}
&.skin-dark {
.left-val { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); color: #f59e0b; }
.btn { color: #d97706 !important; }
}
&.skin-gradient {
.left-val { background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%); }
.btn { color: #8b5cf6 !important; }
}
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="tpl-footer-nav" :style="customStyle">
<div class="footer-wrap">
<!-- 1. 服务保障条 -->
<div class="assurances" v-if="options.showAssurance !== false && options.assurances?.length">
<component
:is="item.url ? 'a' : 'div'"
v-for="(item, idx) in options.assurances"
:key="idx"
class="ass-item"
:class="{ 'is-link': !!item.url }"
:href="item.url || 'javascript:;'"
@click.prevent
>
<span class="code">{{ item.code }}</span>
<div class="ass-text">
<span class="t">{{ item.title }}</span>
<span class="desc" v-if="item.desc">{{ item.desc }}</span>
</div>
</component>
</div>
<!-- 2. 帮助与指南分组列表 -->
<div class="guide-groups" v-if="options.guideGroups && options.guideGroups.length">
<div v-for="(group, gIdx) in options.guideGroups" :key="gIdx" class="guide-col">
<div class="guide-title">{{ group.title }}</div>
<div class="guide-links" v-if="group.links && group.links.length">
<a
v-for="(link, lIdx) in group.links"
:key="lIdx"
class="guide-link"
:href="link.url || 'javascript:;'"
@click.prevent
>
{{ link.text }}
</a>
</div>
</div>
</div>
<!-- 3. 版权与 ICP 备案 -->
<div class="copyright">
<template v-if="options.icpText">
<a
v-if="options.icpLink"
class="footer-link"
:href="options.icpLink"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ options.icpText }}</a>
<span v-else>{{ options.icpText }}</span>
<span class="divider"> | </span>
</template>
<a
v-if="options.copyrightLink"
class="footer-link"
:href="options.copyrightLink"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ options.copyright || 'Copyright © 2026 Lilishop. All Rights Reserved.' }}</a>
<span v-else>{{ options.copyright || 'Copyright © 2026 Lilishop. All Rights Reserved.' }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
let bg = s.backgroundColor;
if (bg && /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(bg.trim())) {
bg = bg.trim().replace(/,\s*0\s*\)$/, ', 1)');
}
return {
backgroundColor: (bg && bg !== 'transparent') ? bg : 'transparent'
};
});
</script>
<style lang="scss" scoped>
.tpl-footer-nav {
width: 100%;
padding: 30px 0 20px;
user-select: none;
.footer-wrap {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
.assurances {
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: 24px;
border-bottom: 1px solid #e2e8f0;
.ass-item {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
&.is-link {
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
opacity: 0.85;
transform: translateY(-1px);
}
}
.code {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid #e11d48;
color: #e11d48;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.ass-text {
display: flex;
flex-direction: column;
gap: 2px;
.t {
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.2;
}
.desc {
font-size: 12px;
color: #64748b;
line-height: 1.2;
}
}
}
}
.guide-groups {
display: flex;
justify-content: space-between;
padding: 28px 20px 24px;
border-bottom: 1px solid #e2e8f0;
.guide-col {
display: flex;
flex-direction: column;
.guide-title {
font-size: 14px;
font-weight: 600;
color: #334155;
margin-bottom: 12px;
}
.guide-links {
display: flex;
flex-direction: column;
gap: 8px;
.guide-link {
font-size: 12px;
color: #64748b;
text-decoration: none;
transition: color 0.15s ease;
&:hover {
color: #e11d48;
}
}
}
}
}
.copyright {
text-align: center;
padding-top: 20px;
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
.footer-link {
color: inherit;
text-decoration: none;
transition: color 0.15s ease;
&:hover {
color: #e11d48;
text-decoration: underline;
}
}
.divider {
margin: 0 4px;
}
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,313 @@
<template>
<div class="tpl-goods-list" :style="customStyle">
<!-- 标题栏 -->
<div class="floor-title-bar" v-if="options.showTitle !== false && options.title">
<div class="title-left">
<span class="main-title">{{ options.title }}</span>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<span class="more-link" v-if="options.showMoreLink !== false">
{{ options.moreText || '查看更多' }} &gt;
</span>
</div>
<!-- 6 列商品列表网格 (整行展示 6 个商品) -->
<div
class="goods-flow-grid"
:class="`cols-${actualColumns}`"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in sampleGoods"
:key="g.id || idx"
class="goods-flow-card"
>
<div class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="thumb-img" />
<span class="self-tag" v-if="idx % 3 === 0">自营</span>
</div>
<div class="card-body">
<div class="goods-name" v-if="options.showTitle !== false">
{{ g.goodsName }}
</div>
<div class="meta-row" v-if="options.showSales !== false">
<span class="sales-text">已售 {{ 120 + idx * 85 }}+ 件</span>
<span class="rate-text">99%好评</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="symbol">¥</span>
<span class="price-val" v-if="options.showPrice !== false">{{ g.price }}</span>
<span class="orig-price" v-if="options.showOriginalPrice && g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button
type="button"
class="add-cart-circle"
v-if="options.showCartBtn !== false"
title="加入购物车"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const defaultMockGoods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' },
{ id: '9', goodsName: '折叠屏旗舰手机 典藏陶瓷白 悬停双旋水滴铰链', price: '6999.00', originalPrice: '7999.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '10', goodsName: '真无线触控降噪蓝牙耳机 超低延迟游戏降噪', price: '599.00', originalPrice: '799.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '11', goodsName: '国AA级减蓝光润眼长臂台灯 智能感光多档色温无极调光', price: '249.00', originalPrice: '359.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '12', goodsName: '航空级铝镁合金静音万向轮拉杆登机箱 20英寸防刮耐磨', price: '499.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' }
];
const actualColumns = computed(() => {
// 商品列表整行默认 6 列
let baseCols = Number(props.options?.columns) || 6;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 3);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 4);
}
return baseCols; // 100% 默认 6 列
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '14px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
const sampleGoods = computed(() => {
if (Array.isArray(props.options.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options.goodsCount || (actualColumns.value * 2) || 12;
return defaultMockGoods.slice(0, count);
});
</script>
<style lang="scss" scoped>
.tpl-goods-list {
width: 100%;
user-select: none;
box-sizing: border-box;
.floor-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 0 4px;
.title-left {
display: flex;
align-items: baseline;
gap: 10px;
.main-title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.sub-title {
font-size: 13px;
color: #94a3b8;
}
}
.more-link {
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
.goods-flow-grid {
display: grid;
gap: 12px;
.goods-flow-card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #f1f5f9;
padding: 10px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.thumb-img {
transform: scale(1.05);
}
.add-cart-circle {
background: #e11d48;
color: #ffffff;
}
}
.card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.self-tag {
position: absolute;
top: 6px;
left: 6px;
background: #e11d48;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-name {
font-size: 12px;
color: #1e293b;
line-height: 1.4;
height: 34px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #94a3b8;
margin-bottom: 6px;
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.symbol {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
.price-val {
font-size: 16px;
font-weight: 800;
color: #e11d48;
}
.orig-price {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 3px;
}
}
.add-cart-circle {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<div class="tpl-goods-grid" :style="customStyle">
<!-- 宫格顶部标题区 -->
<div class="grid-header" v-if="options.title">
<div class="grid-title-wrap">
<span class="grid-title-decor"></span>
<h3 class="grid-main-title">{{ options.title }}</h3>
<span class="grid-sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="grid-more" v-if="options.showMoreLink !== false">
<span>{{ options.moreText || '查看全部' }} &gt;</span>
</div>
</div>
<!-- 紧凑严谨的商品九宫格/矩阵网格 (Bordered Matrix Grid) -->
<div
class="matrix-grid-container"
:class="[`cols-${actualColumns}`, { 'with-grid-border': options.showBorder !== false }]"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="matrix-grid-cell"
>
<div class="cell-rank-tag" v-if="idx < 5">
<span class="rank-num">TOP {{ idx + 1 }}</span>
</div>
<div class="cell-thumb-box">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" class="cell-thumb" />
</div>
<div class="cell-body">
<div class="cell-name">{{ item.goodsName }}</div>
<div class="cell-meta">
<span class="badge-label">热卖好物</span>
</div>
<div class="cell-bottom-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
<span class="del" v-if="item.originalPrice">¥{{ item.originalPrice }}</span>
</div>
<span class="action-add-pill" title="立即选购">+</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const actualColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
const base = Number(props.options?.columns) || 5;
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return base; // 100% 默认 5 列宫格
});
const defaultGridGoods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '10', goodsName: '15.6英寸 4K超高清便携式显示器 Type-C一线直连高色域', price: '1299.00', originalPrice: '1699.00', thumbnail: '/static/goods/goods_3.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = (actualColumns.value * 2) || 10;
return defaultGridGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '18px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-goods-grid {
width: 100%;
box-sizing: border-box;
user-select: none;
.grid-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
.grid-title-wrap {
display: flex;
align-items: center;
gap: 10px;
.grid-title-decor {
width: 4px;
height: 18px;
background: #f97316;
border-radius: 2px;
}
.grid-main-title {
font-size: 19px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.grid-sub-title {
font-size: 12px;
color: #64748b;
margin-left: 4px;
}
}
.grid-more {
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #f97316;
}
}
}
/* 宫格矩阵:带整体边框与单元格实线分隔线 */
.matrix-grid-container {
display: grid;
background: #ffffff;
box-sizing: border-box;
&.with-grid-border {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
.matrix-grid-cell {
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
}
}
.matrix-grid-cell {
position: relative;
padding: 16px 14px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
transition: all 0.2s ease;
&:hover {
background: #fafbfc;
z-index: 1;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
.cell-thumb {
transform: scale(1.06);
}
.action-add-pill {
background: #f97316;
color: #ffffff;
}
}
.cell-rank-tag {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
.rank-num {
display: inline-block;
font-size: 10px;
font-weight: 800;
color: #f97316;
background: #fff7ed;
border: 1px solid #fed7aa;
padding: 1px 6px;
border-radius: 4px;
}
}
.cell-thumb-box {
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 12px;
.cell-thumb {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
}
.cell-body {
display: flex;
flex-direction: column;
flex: 1;
.cell-name {
font-size: 13px;
color: #334155;
font-weight: 500;
line-height: 1.4;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 6px;
}
.cell-meta {
display: flex;
margin-bottom: 8px;
.badge-label {
font-size: 10px;
color: #d97706;
background: #fef3c7;
padding: 1px 6px;
border-radius: 3px;
}
}
.cell-bottom-row {
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
gap: 1px;
.sym {
font-size: 12px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 18px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.action-add-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #f1f5f9;
color: #64748b;
font-size: 15px;
font-weight: 600;
line-height: 1;
transition: all 0.2s;
cursor: pointer;
}
}
}
}
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,307 @@
<template>
<div
class="tpl-notice-bar"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
@mouseenter="stopTimer"
@mouseleave="startTimer"
>
<!-- 左侧快讯徽章 -->
<div class="notice-badge-box">
<div class="badge-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/>
</svg>
</div>
<span class="badge-title">{{ options.title || '公告快讯' }}</span>
<span class="badge-divider"></span>
</div>
<!-- 中间动态滚动快讯区 (Vertical Carousel) -->
<div class="notice-content-viewport">
<div
class="notice-items-strip"
:style="{ transform: `translateY(-${currentIndex * 40}px)` }"
>
<div
v-for="(item, idx) in noticeList"
:key="idx"
class="notice-strip-item"
>
<span class="notice-item-tag" :class="getTagClass(item.tag)">
{{ item.tag || '最新' }}
</span>
<span class="notice-item-title">{{ item.title || item.text }}</span>
<span class="notice-item-time" v-if="item.time && !isMini && !isCompact">
{{ item.time }}
</span>
</div>
</div>
</div>
<!-- 右侧更多链接与翻页指示 -->
<div class="notice-more-action" v-if="options.showMore !== false && !isMini">
<span class="more-text">{{ options.moreText || '更多' }} &gt;</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const isMini = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '16.67%' || r === '16.6%' || r === '16.7%';
});
const isCompact = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '33.33%' || r === '33.3%';
});
const defaultItems = [
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
];
const noticeList = computed(() => {
const list = props.options?.items || props.options?.notices;
if (Array.isArray(list) && list.length > 0) {
return list;
}
return defaultItems;
});
const currentIndex = ref(0);
let timer = null;
function startTimer() {
if (timer) clearInterval(timer);
if (noticeList.value.length <= 1) return;
timer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % noticeList.value.length;
}, 3500);
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function getTagClass(tag) {
if (tag === '活动') return 'tag-promo';
if (tag === '通知') return 'tag-notice';
if (tag === '热议') return 'tag-hot';
return 'tag-new';
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '0 16px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-notice-bar {
width: 100%;
height: 44px;
display: flex;
align-items: center;
box-sizing: border-box;
user-select: none;
border: 1px solid #eef2f6;
gap: 12px;
/* 左侧徽章 */
.notice-badge-box {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
.badge-icon {
color: #ef4444;
display: flex;
align-items: center;
}
.badge-title {
font-size: 14px;
font-weight: 800;
color: #0f172a;
white-space: nowrap;
letter-spacing: -0.2px;
}
.badge-divider {
width: 1px;
height: 14px;
background: #e2e8f0;
margin-left: 6px;
}
}
/* 中间快讯轮播视口 */
.notice-content-viewport {
flex: 1;
height: 40px;
overflow: hidden;
position: relative;
min-width: 0;
.notice-items-strip {
transition: transform 0.4s ease-in-out;
.notice-strip-item {
height: 40px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
.notice-item-tag {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
flex-shrink: 0;
&.tag-new {
background: #fee2e2;
color: #dc2626;
}
&.tag-promo {
background: #ffedd5;
color: #ea580c;
}
&.tag-notice {
background: #eff6ff;
color: #2563eb;
}
&.tag-hot {
background: #fdf2f8;
color: #db2777;
}
}
.notice-item-title {
flex: 1;
font-size: 13px;
color: #334155;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
transition: color 0.2s;
}
.notice-item-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
flex-shrink: 0;
margin-right: 4px;
}
}
}
}
/* 右侧更多 */
.notice-more-action {
flex-shrink: 0;
font-size: 12px;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #ef4444;
}
}
/* 紧凑形态 (三分之一) */
&.is-compact {
gap: 8px;
.notice-badge-box {
.badge-title {
font-size: 13px;
}
.badge-divider {
margin-left: 2px;
}
}
.notice-content-viewport {
.notice-strip-item {
.notice-item-title {
font-size: 12px;
}
}
}
}
/* 极简微缩形态 (六分之一) */
&.is-mini {
gap: 4px;
padding: 0 8px !important;
.notice-badge-box {
.badge-title {
display: none;
}
.badge-divider {
display: none;
}
}
.notice-content-viewport {
.notice-strip-item {
.notice-item-tag {
display: none;
}
.notice-item-title {
font-size: 11px;
}
}
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,544 @@
<template>
<div class="tpl-recommend-feed" :style="customStyle">
<!-- 1. 推荐流居中标题栏 -->
<div class="recommend-feed-header">
<div class="header-decor-line left"></div>
<div class="header-center-title">
<svg class="heart-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<h3 class="main-title">{{ options.title || '猜你喜欢' }}</h3>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="header-decor-line right"></div>
</div>
<!-- 2. 推荐流布局:精选大卡+网格 (featured) 或 标准网格 (grid) -->
<!-- A. 精选大卡+网格 (featured 布局) -->
<div
v-if="(options.layoutStyle || 'featured') === 'featured'"
class="recommend-featured-layout"
>
<!-- 左侧精选特大卡片 (Featured Hero Card) -->
<div class="featured-big-card" v-if="featuredGoods">
<div class="big-card-badge">为你特别甄选</div>
<div class="big-card-thumb">
<img :src="featuredGoods.thumbnail || defaultThumb" :alt="featuredGoods.goodsName" />
</div>
<div class="big-card-info">
<div class="big-title">{{ featuredGoods.goodsName }}</div>
<div class="big-reason">99.8% 高好评 · 猜你对这款高人气科技数码感兴趣</div>
<div class="big-price-row">
<div class="big-price">
<span class="sym">¥</span>
<span class="val">{{ featuredGoods.price }}</span>
<span class="del" v-if="featuredGoods.originalPrice">¥{{ featuredGoods.originalPrice }}</span>
</div>
<button type="button" class="big-buy-btn">立即入手 &gt;</button>
</div>
</div>
</div>
<!-- 右侧与后续标准卡片网格 -->
<div class="featured-sub-grid">
<div
v-for="(item, idx) in regularGoods"
:key="item.id || idx"
class="recommend-card"
>
<div class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">推荐</span>
</div>
<div class="card-body">
<div class="goods-title">{{ item.goodsName }}</div>
<div class="recommend-meta">
<span class="reason-tag">同品类热度榜</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn">+</button>
</div>
</div>
</div>
</div>
</div>
<!-- B. 标准网格流 (grid 布局) -->
<div
v-else
class="recommend-standard-grid"
:class="`cols-${actualCols}`"
:style="{ gridTemplateColumns: `repeat(${actualCols}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="recommend-card"
>
<div class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">为你推荐</span>
</div>
<div class="card-body">
<div class="goods-title">{{ item.goodsName }}</div>
<div class="recommend-meta">
<span class="sales-num">已售 {{ 200 + idx * 70 }}+</span>
<span class="rate-num">98%好评</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn">+</button>
</div>
</div>
</div>
</div>
<!-- 底部换一批 / 探索更多推荐 -->
<div class="recommend-footer-action">
<button type="button" class="refresh-rec-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
<span>换一批推荐好物</span>
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = '/static/goods/goods_1.png';
const defaultPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: '/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: '/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: '/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: '/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: '/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: '/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: '/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: '/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: '/static/goods/goods_1.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options?.pageSize || 8;
return defaultPool.slice(0, count);
});
const featuredGoods = computed(() => {
return displayGoods.value[0] || defaultPool[0];
});
const regularGoods = computed(() => {
return displayGoods.value.slice(1, 7);
});
const actualCols = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return 5;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const bg = props.options?.backgroundColor || s.backgroundColor || '#f8fafc';
return {
backgroundColor: bg,
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '24px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.tpl-recommend-feed {
width: 100%;
box-sizing: border-box;
user-select: none;
.recommend-feed-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
gap: 16px;
.header-decor-line {
flex: 1;
max-width: 120px;
height: 1px;
background: linear-gradient(90deg, transparent, #cbd5e1);
&.right {
background: linear-gradient(90deg, #cbd5e1, transparent);
}
}
.header-center-title {
display: flex;
align-items: center;
gap: 8px;
.heart-icon {
color: #ef4444;
}
.main-title {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin: 0;
}
.sub-title {
font-size: 13px;
color: #64748b;
margin-left: 6px;
}
}
}
/* 精选大卡 + 网格复合布局 */
.recommend-featured-layout {
display: flex;
gap: 14px;
align-items: stretch;
.featured-big-card {
width: 320px;
flex-shrink: 0;
background: #ffffff;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
position: relative;
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.big-card-badge {
position: absolute;
top: 12px;
left: 12px;
background: linear-gradient(135deg, #ef4444, #f97316);
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
z-index: 2;
}
.big-card-thumb {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 6px;
margin-bottom: 12px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
img {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.big-card-info {
display: flex;
flex-direction: column;
flex: 1;
.big-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
line-height: 1.4;
margin-bottom: 6px;
}
.big-reason {
font-size: 12px;
color: #f97316;
background: #fff7ed;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 12px;
line-height: 1.3;
}
.big-price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.big-price {
display: flex;
align-items: baseline;
.sym {
font-size: 13px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 20px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.big-buy-btn {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 6px 14px;
border-radius: 16px;
border: none;
cursor: pointer;
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: scale(1.03);
}
}
}
}
}
.featured-sub-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
}
/* 标准网格流 */
.recommend-standard-grid {
display: grid;
gap: 12px;
}
.recommend-card {
background: #ffffff;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
border: 1px solid #f1f5f9;
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
.card-thumb img {
transform: scale(1.05);
}
.cart-pill-btn {
background: #ef4444;
color: #ffffff;
}
}
.card-thumb {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.rec-tag {
position: absolute;
top: 6px;
left: 6px;
background: #ef4444;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-title {
font-size: 12px;
color: #1e293b;
line-height: 1.4;
height: 34px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
}
.recommend-meta {
font-size: 10px;
color: #f97316;
margin-bottom: 6px;
.reason-tag {
background: #fff7ed;
padding: 1px 5px;
border-radius: 3px;
}
.sales-num {
color: #94a3b8;
margin-right: 6px;
}
.rate-num {
color: #10b981;
}
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.sym {
font-size: 11px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 16px;
font-weight: 800;
color: #dc2626;
}
}
.cart-pill-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
.recommend-footer-action {
display: flex;
justify-content: center;
margin-top: 20px;
.refresh-rec-btn {
display: flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
font-weight: 500;
padding: 8px 24px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: all 0.2s;
&:hover {
border-color: #ef4444;
color: #ef4444;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
}
}
}
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<div class="tpl-search-header" :style="customStyle">
<div class="header-inner">
<div class="logo-area">
<img v-if="options.logoUrl" :src="options.logoUrl" alt="logo" class="logo-img" />
<span v-else class="text-logo">LiliShop</span>
</div>
<div class="search-main">
<div class="search-box">
<input
type="text"
class="mock-input"
:placeholder="options.placeholder || '搜索品牌或商品'"
readonly
/>
<button type="button" class="search-btn">
<el-icon><Search /></el-icon>
<span>搜索</span>
</button>
</div>
<div class="hot-words" v-if="options.showHotWords !== false && options.hotWords?.length">
<span v-for="(w, idx) in options.hotWords" :key="idx" class="hot-w">{{ w }}</span>
</div>
</div>
<div
class="cart-btn"
v-if="options.showCart !== false"
:style="cartBtnStyle"
>
<el-icon class="cart-icon" :style="{ color: options.cartBadgeColor || '#e11d48' }">
<ShoppingCart />
</el-icon>
<span>{{ options.cartText || '我的购物车' }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Search, ShoppingCart } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
minHeight: `${s.height || 90}px`
};
});
const cartBtnStyle = computed(() => {
const o = props.options || {};
return {
backgroundColor: o.cartBgColor || '#ffffff',
borderColor: o.cartBorderColor || '#e2e8f0',
color: o.cartTextColor || '#334155'
};
});
</script>
<style lang="scss" scoped>
.tpl-search-header {
width: 100%;
display: flex;
align-items: center;
user-select: none;
.header-inner {
width: 100%;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
.logo-area {
.logo-img {
max-height: 48px;
object-fit: contain;
}
.text-logo {
font-size: 24px;
font-weight: 800;
color: #e11d48;
}
}
.search-main {
flex: 1;
max-width: 550px;
.search-box {
display: flex;
border: 2px solid #e11d48;
border-radius: 4px;
overflow: hidden;
.mock-input {
flex: 1;
border: none;
padding: 8px 12px;
font-size: 13px;
outline: none;
background: #ffffff;
}
.search-btn {
background: #e11d48;
color: #ffffff;
border: none;
padding: 0 20px;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
}
.hot-words {
margin-top: 6px;
display: flex;
gap: 10px;
font-size: 11px;
color: #94a3b8;
}
}
.cart-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 13px;
color: #334155;
background: #f8fafc;
.cart-icon {
color: #e11d48;
}
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,129 @@
<template>
<div class="tpl-top-bar" :style="customStyle">
<div class="bar-inner">
<div class="bar-left">
<span class="welcome-txt">{{ options.welcomeText || '欢迎来到商城' }}</span>
<span class="auth-links">
<span class="auth-btn">[{{ options.loginText || '登录' }}]</span>
<span class="auth-btn">[{{ options.registerText || '免费注册' }}]</span>
</span>
</div>
<div class="bar-right">
<span
v-for="(menu, idx) in (options.rightMenus || defaultMenus)"
:key="idx"
class="bar-link"
>
{{ menu.title }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultMenus = [
{ title: '基本信息', url: '/home/UserInfo' },
{ title: '我的订单', url: '/home/MyOrder' },
{ title: '账户安全', url: '/home/AccountSafe' },
{ title: '消息', url: '/home/Msg' }
];
const customStyle = computed(() => {
const s = props.componentStyle || {};
const opt = props.options || {};
const textColor = opt.textColor || s.textColor || '#4b5563';
const fontSize = opt.fontSize ? `${opt.fontSize}px` : '12px';
return {
backgroundColor: s.backgroundColor || 'transparent',
color: textColor,
fontSize: fontSize,
height: `${s.height || 36}px`,
lineHeight: `${s.height || 36}px`
};
});
</script>
<style lang="scss" scoped>
.tpl-top-bar {
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-size: 12px;
user-select: none;
overflow: hidden;
*, *::before, *::after {
box-sizing: border-box;
}
.bar-inner {
width: 100%;
max-width: 100%;
padding: 0 24px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
.bar-left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
white-space: nowrap;
.welcome-txt {
white-space: nowrap;
}
.auth-links {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
.auth-btn {
color: #ff6a00;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
.bar-right {
display: flex;
align-items: center;
gap: 18px;
flex-shrink: 0;
white-space: nowrap;
.bar-link {
cursor: pointer;
transition: color 0.2s;
white-space: nowrap;
&:hover {
color: #ff6a00;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,399 @@
<template>
<div class="tpl-user-benefits" :style="customStyle">
<!-- 1. 用户头像与信息区 -->
<div class="user-main-info">
<div class="avatar-box">
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
<div v-else class="avatar-placeholder">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</div>
</div>
<div class="user-greeting">
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
{{ options.welcomeText || '欢迎来到商城' }}
</div>
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
{{ options.subtitle || '登录后查看专属权益' }}
</div>
</div>
</div>
<!-- 2. 登录 / 注册按钮组 -->
<div class="action-btn-row">
<div class="action-btn login-btn">{{ options.loginText || '登录' }}</div>
<div class="action-btn register-btn">{{ options.registerText || '注册' }}</div>
</div>
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<div class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</div>
<div class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</div>
<div class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</div>
</div>
<!-- 4. 分隔线 -->
<div class="section-divider"></div>
<!-- 5. 热点快报(万米魔方标准组件底部模块) -->
<div class="news-section">
<div class="news-header">
<div class="news-title-wrap">
<span class="news-title">热点快报</span>
<span class="hot-badge">HOT</span>
</div>
<span class="news-more">查看全部 &gt;</span>
</div>
<div class="news-list">
<div class="news-item" title="精选优质好物 官方正品保障">
<span class="dot">·</span>
<span class="news-text">精选优质好物 官方正品保障</span>
</div>
<div class="news-item" title="双11狂欢 跨店每满300减50">
<span class="dot">·</span>
<span class="news-text">双11狂欢 跨店每满300减50</span>
</div>
<div class="news-item" title="新人专享好礼 注册立领大礼包">
<span class="dot">·</span>
<span class="news-text">新人专享好礼 注册立领大礼包</span>
</div>
</div>
</div>
<!-- 6. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<div class="service-item">
<el-icon class="service-icon"><Star /></el-icon>
<span class="service-name">收藏</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Clock /></el-icon>
<span class="service-name">足迹</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Present /></el-icon>
<span class="service-name">红包</span>
</div>
<div class="service-item">
<el-icon class="service-icon"><Service /></el-icon>
<span class="service-name">客服</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-user-benefits {
width: 100%;
height: 100%;
min-height: 420px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
padding: 14px 12px 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
.user-main-info {
display: flex;
align-items: center;
gap: 8px;
.avatar-box {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
}
}
.user-greeting {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
.badge-tag {
align-self: flex-start;
padding: 1px 6px;
background: #fff2e8;
color: #ff6000;
font-size: 10px;
font-weight: 700;
border-radius: 8px;
line-height: 1.2;
margin-bottom: 2px;
}
.welcome-text {
font-size: 13px;
font-weight: 700;
color: #111827;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub-text {
font-size: 11px;
color: #94a3b8;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.action-btn-row {
display: flex;
gap: 8px;
margin-top: 10px;
.action-btn {
flex: 1;
height: 28px;
line-height: 28px;
font-size: 12px;
font-weight: 500;
text-align: center;
border-radius: 14px;
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s ease;
&.login-btn {
background: #ff6000;
color: #ffffff;
box-shadow: 0 2px 6px rgba(255, 96, 0, 0.25);
&:hover {
background: #e55600;
}
}
&.register-btn {
background: #ffffff;
color: #ff6000;
border: 1px solid #ff6000;
&:hover {
background: #fff7ed;
}
}
}
}
.metrics-cards-row {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 0;
margin-top: 10px;
background: #f8fafc;
border-radius: 6px;
.metric-card {
text-align: center;
flex: 1;
cursor: pointer;
.metric-val {
font-size: 15px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.metric-label {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
&:hover {
.metric-val, .metric-label {
color: #ff6000;
}
}
}
}
.section-divider {
height: 1px;
background: #f1f5f9;
margin: 8px 0 6px;
}
.news-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2px;
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.news-title-wrap {
display: flex;
align-items: center;
gap: 4px;
.news-title {
font-size: 12px;
font-weight: 700;
color: #1e293b;
}
.hot-badge {
background: #ef4444;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 0 3px;
border-radius: 2px;
line-height: 1.2;
}
}
.news-more {
font-size: 11px;
color: #94a3b8;
cursor: pointer;
&:hover {
color: #ff6000;
}
}
}
.news-list {
display: flex;
flex-direction: column;
gap: 5px;
.news-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #475569;
cursor: pointer;
line-height: 1.3;
transition: color 0.15s;
.dot {
color: #ff6000;
font-weight: 700;
}
.news-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
color: #ff6000;
}
}
}
}
.quick-service-row {
display: flex;
align-items: center;
justify-content: space-around;
padding-top: 8px;
border-top: 1px dashed #f1f5f9;
margin-top: 4px;
.service-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
color: #64748b;
transition: color 0.15s;
.service-icon {
font-size: 14px;
}
.service-name {
font-size: 10px;
line-height: 1;
}
&:hover {
color: #ff6000;
}
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="tpl-video" :style="customStyle">
<div class="video-preview-box">
<img :src="options.coverUrl || '/static/video/video_cover.png'" alt="cover" class="cover-img" />
<div class="play-btn">▶</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 360}px`,
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.tpl-video {
width: 100%;
background: #0f172a;
overflow: hidden;
user-select: none;
box-sizing: border-box;
.video-preview-box {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-btn {
position: absolute;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(225, 29, 72, 0.9);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.08);
}
}
}
}
</style>

View File

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

View File

@@ -262,62 +262,83 @@
<!-- 遮罩层 -->
<div v-if="isRead" class="mask">只读不可修改</div>
<el-form-item label="经营类目" prop="goodsManagementCategory">
<div>
<el-checkbox
v-model="checkAll"
:indeterminate="indeterminate"
@change="handleCheckAll"
>全选
</el-checkbox>
<div class="category-panel">
<div class="category-toolbar">
<el-checkbox
v-model="checkAll"
:indeterminate="indeterminate"
@change="handleCheckAll"
>全选
</el-checkbox>
<span class="category-count"
>已选 {{ checkAllGroup.length }} / {{ categories.length }} 项</span
>
</div>
<el-checkbox-group
v-model="checkAllGroup"
class="category-grid"
@change="checkAllGroupChange"
>
<el-checkbox
v-for="(item, i) in categories"
:key="i + 1"
:value="String(item.id)"
>{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</div>
<el-checkbox-group v-model="checkAllGroup" @change="checkAllGroupChange">
<el-checkbox
v-for="(item, i) in categories"
:key="i + 1"
:value="String(item.id)"
>{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="结算信息" class="tab" name="settlement">
<el-alert type="info" :closable="false" show-icon>
<el-alert type="info" :closable="false" show-icon class="settlement-alert">
已添加<span class="theme_color">{{ settlementCycle.length }}</span
>个结算日,最多可添加5个结算日,当月不包含所设日期时,将会顺延到下一个结算日
</el-alert>
<el-form-item label="结算日期">
<el-tag
v-for="item in settlementCycle"
:key="item"
closable
style="margin-left: 10px"
@close="removesettlementCycle(item)"
>{{ item }}
</el-tag>
<el-input-number
size="small"
:max="31"
:min="1"
v-model="day"
v-show="settlementShow"
/>
<el-button
type="default"
@click="addsettlementCycle"
size="small"
v-if="addSettlementBtn && settlementCycle.length < 5"
style="margin-left: 8px"
>添加结算日期
</el-button>
<el-button
v-if="addSettlementConfirmBtn"
type="default"
@click="addsettlementCycleConfirm"
size="small"
style="margin-left: 8px"
>确认
</el-button>
<div class="settlement-panel">
<div class="settlement-tags">
<el-tag
v-for="item in settlementCycle"
:key="item"
closable
class="settlement-tag"
@close="removesettlementCycle(item)"
>每月 {{ item }} 日
</el-tag>
<span v-if="!settlementCycle.length" class="settlement-empty"
>暂未添加结算日</span
>
</div>
<div class="settlement-actions">
<template v-if="settlementShow">
<el-input-number
size="small"
:max="31"
:min="1"
v-model="day"
/>
<el-button
v-if="addSettlementConfirmBtn"
type="primary"
@click="addsettlementCycleConfirm"
size="small"
>确认
</el-button>
<el-button type="default" @click="cancelSettlementCycle" size="small"
>取消
</el-button>
</template>
<el-button
type="primary"
plain
@click="addsettlementCycle"
size="small"
v-if="addSettlementBtn && settlementCycle.length < 5"
>+ 添加结算日期
</el-button>
</div>
</div>
</el-form-item>
</el-tab-pane>
</el-tabs>
@@ -605,6 +626,13 @@ function addsettlementCycleConfirm() {
settlementShow.value = false;
day.value = 1;
}
//取消添加结算日
function cancelSettlementCycle() {
addSettlementConfirmBtn.value = false;
addSettlementBtn.value = true;
settlementShow.value = false;
day.value = 1;
}
// 选择公司地址
function selectedRegion(val) {
shopForm.value.companyAddressIdPath = val[0].toString();
@@ -846,9 +874,101 @@ init();
position: relative;
}
.categories-checkbox {
.category-panel {
width: 100%;
max-width: 960px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.category-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #f8f9fb;
border-bottom: 1px solid #e4e7ed;
:deep(.el-checkbox) {
height: 32px;
}
}
.category-count {
font-size: 12px;
color: #909399;
}
.settlement-alert {
margin-bottom: 16px;
}
.settlement-panel {
width: 100%;
max-width: 960px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.settlement-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 16px;
min-height: 60px;
}
.settlement-tag {
margin: 0;
}
.settlement-empty {
color: #909399;
font-size: 13px;
}
.settlement-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-top: 1px solid #ebeef5;
background: #f8f9fb;
}
.category-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 16px;
:deep(.el-checkbox) {
height: auto;
margin-right: 0;
padding: 5px 14px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
transition: border-color 0.2s, background-color 0.2s;
&:hover {
border-color: var(--el-color-primary, #409eff);
}
&.is-checked {
border-color: var(--el-color-primary, #409eff);
background-color: var(--el-color-primary-light-9, #ecf5ff);
}
.el-checkbox__label {
padding-left: 6px;
}
}
}
.img {

View File

@@ -40,7 +40,7 @@
</template>
</el-table-column>
<el-table-column prop="goodsName" label="销售商品" min-width="160" />
<el-table-column prop="goodsId" label="商品ID" width="140" />
<el-table-column prop="goodsId" label="SPU编码" width="140" />
<el-table-column prop="salesNum" label="商品销售数量" width="110" />
<el-table-column prop="salesAmount" label="商品销售金额" width="120">
<template #default="{ row }">{{ formatMoney(row.salesAmount) }}</template>

View File

@@ -30,35 +30,48 @@
}
.img-box {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-start;
align-content: flex-start;
margin-top: 20px;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
margin-top: 16px;
box-sizing: border-box;
.img-item {
width: 120px;
height: 158px;
width: 100%;
box-sizing: border-box;
margin: 0 13px 8px;
margin: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
.card {
width: 120px;
height: 120px;
width: 100%;
aspect-ratio: 1 / 1;
height: auto;
border-radius: 8px;
border: 2px solid transparent;
border: 2px solid #eef2f6;
overflow: hidden;
box-sizing: border-box;
position: relative;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
transition: all 0.2s ease;
img,
.card-media {
width: 100%;
height: 100%;
object-fit: cover;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: auto;
}
.card-file {
width: 100%;
height: 100%;
@@ -68,6 +81,7 @@
background: #f5f7fa;
color: #909399;
}
.card-checkbox {
position: absolute;
top: 8px;
@@ -80,48 +94,48 @@
display: none;
}
}
.preview {
width: 100%;
height: 26px;
background-color: #ffffff;
color: #666666;
position: absolute;
left: 0;
bottom: 0;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.12);
> div {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
}
}
.card:hover,
.card:hover {
border-color: #ff6a00;
}
.custom-checkbox-card-checked {
border: 2px solid #ff5c58;
border: 2px solid #ff6a00 !important;
background: #fffaf5 !important;
}
.oss-card-selectable {
cursor: pointer;
}
.text {
width: 120px;
height: 36px;
width: 100%;
max-width: 100%;
height: 20px;
cursor: pointer;
color: #252931;
font-size: 14px;
line-height: 36px;
font-size: 13px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
margin-top: 6px;
}
.sub-info {
width: 100%;
max-width: 100%;
height: 18px;
color: #94a3b8;
font-size: 11px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
margin-top: 2px;
}
}
}
@@ -140,3 +154,100 @@
}
}
.pc-image-picker-dialog {
margin-top: 16px !important;
margin-bottom: 16px !important;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
.el-dialog__header {
padding: 12px 20px 8px;
flex-shrink: 0;
}
.el-dialog__body {
padding: 6px 16px 12px;
overflow: hidden !important;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
flex: 1;
&::-webkit-scrollbar {
display: none !important; /* Chrome, Safari, Edge */
width: 0 !important;
height: 0 !important;
}
}
}
.is-component-search {
.is-component-card {
border: none !important;
box-shadow: none !important;
background: transparent !important;
width: 100%;
.el-card__body {
padding: 0 !important;
}
}
.oss-manage-box {
.file-list {
padding: 0 10px 10px;
}
.pic-list {
padding: 0;
}
.oss-operation {
margin-bottom: 6px;
}
.img-box {
margin-top: 6px;
gap: 10px 12px;
.img-item {
.card {
max-width: 128px;
margin: 0 auto;
}
.text {
height: 18px;
line-height: 18px;
font-size: 12px;
margin-top: 4px;
}
.sub-info {
height: 16px;
line-height: 16px;
font-size: 11px;
margin-top: 1px;
}
}
}
.page-box {
margin-top: 8px;
}
.oss-select-footer {
margin-top: 8px;
padding-top: 8px;
}
}
.oss-group-tree {
padding: 0;
.el-tree-node {
margin-bottom: 4px;
}
.el-tree-node__content {
height: 36px;
line-height: 36px;
font-size: 14px;
}
}
}

View File

@@ -124,7 +124,7 @@
</style>
<template>
<div class="search">
<div class="search" :class="{ 'is-component-search': isComponent }">
<el-card v-if="!isComponent" class="tabs-card">
<el-tabs v-model="activeRoleTab" @tab-change="handleRoleTabChange">
<el-tab-pane label="管理员" name="MANAGER" />
@@ -133,15 +133,15 @@
<el-tab-pane label="客服" name="CUSTOMER" />
</el-tabs>
</el-card>
<el-card class="tabs-card category-tabs-card">
<el-card v-if="!isComponent" class="tabs-card category-tabs-card">
<el-tabs v-model="activeCategoryTab" @tab-change="handleCategoryTabChange">
<el-tab-pane label="图片" name="IMAGE" />
<el-tab-pane label="视频" name="VIDEO" />
<el-tab-pane v-if="!isComponent" label="文件" name="FILE" />
<el-tab-pane v-if="allowImage" label="图片" name="IMAGE" />
<el-tab-pane v-if="allowVideo" label="视频" name="VIDEO" />
<el-tab-pane v-if="!isComponent && !onlyImage" label="文件" name="FILE" />
</el-tabs>
</el-card>
<el-row>
<el-card>
<component :is="isComponent ? 'div' : 'el-card'" :class="{ 'is-component-card': isComponent }">
<div>
<el-row class="oss-manage-box">
<el-col v-if="canShowGroups" :span="isComponent ? 5 : 4">
@@ -217,8 +217,7 @@
'custom-checkbox-card-checked': selectedOss.includes(item.id + ',' + item.url),
'oss-card-selectable': isComponent && selectImage,
}"
@mouseenter="onMouseOver(item, index)"
@mouseleave="onMouseOut(item, index)"
@click="handleCardClick(item, $event)"
>
<el-checkbox
@@ -236,29 +235,12 @@
<div v-else class="card-file">
<el-icon :size="28"><Document /></el-icon>
</div>
<div v-if="item.isShowPreview" class="preview">
<div @click.prevent="download(item)">
<el-tooltip content="下载" placement="top">
<el-icon :size="18"><Download /></el-icon>
</el-tooltip>
</div>
<div @click.prevent="remove(item)">
<el-tooltip content="删除" placement="top">
<el-icon :size="18"><Delete /></el-icon>
</el-tooltip>
</div>
<div
@click.prevent="previewFile(item)"
>
<el-tooltip content="预览" placement="top">
<el-icon :size="22"><View /></el-icon>
</el-tooltip>
</div>
</div>
</div>
<el-tooltip :content="item.name" placement="bottom">
<div class="text">{{ item.name }}</div>
</el-tooltip>
<div class="sub-info">{{ getFileSubInfo(item) }}</div>
</div>
</div>
</el-checkbox-group>
@@ -282,7 +264,7 @@
</el-row>
</div>
</el-card>
</component>
</el-row>
<el-dialog
v-model="modalVisible"
@@ -427,6 +409,26 @@ const props = defineProps({
type: Boolean,
default: false,
},
mediaType: {
type: String,
default: "",
},
onlyImage: {
type: Boolean,
default: false,
},
});
const allowVideo = computed(() => {
if (props.onlyImage || props.mediaType === "IMAGE") return false;
if (props.isComponent && fileType.value === "pic") return false;
return true;
});
const allowImage = computed(() => {
if (props.mediaType === "VIDEO") return false;
if (props.isComponent && fileType.value === "video") return false;
return true;
});
const emit = defineEmits(["callback", "selected"]);
@@ -834,7 +836,7 @@ const viewColumns = ref([
]);
const data = ref([]); // 表单数据
const total = ref(0); // 表单数据总数
const pageSizeOpts = ref([5, 10, 20]); // 页码展示项
const pageSizeOpts = ref([20, 40, 60]); // 页码展示项
const list = ref([]); // 列表
//树结构
const treeData = ref([]);
@@ -921,6 +923,10 @@ watch(
selectedOss.value = [];
activeRoleTab.value = "MANAGER";
searchForm.userEnums = "MANAGER";
if (!allowVideo.value) {
activeCategoryTab.value = "IMAGE";
searchForm.fileCategory = "IMAGE";
}
delete searchForm.fileDirectoryId;
selectedGroupData.value = "";
}
@@ -994,13 +1000,8 @@ function onTreeDropdownCommand(cmd, data) {
handleContextMenuDelete();
}
}
function onMouseOver(item, index) {
data.value[index].isShowPreview = true;
proxy.$forceUpdate();
}
function onMouseOut(item, index) {
data.value[index].isShowPreview = false;
}
function onMouseOver() {}
function onMouseOut() {}
function handleRoleTabChange(name) {
activeRoleTab.value = name;
searchForm.pageNumber = 1;
@@ -1021,7 +1022,7 @@ function handleCategoryTabChange(name) {
if (showType.value === "list") {
searchForm.pageSize = 20;
} else {
searchForm.pageSize = 12;
searchForm.pageSize = 20;
}
getDataList();
}
@@ -1071,7 +1072,7 @@ function selectOssChange(e) {
}
function handleCardClick(item, event) {
if (!(props.isComponent && selectImage.value)) return;
if (event?.target?.closest?.(".preview") || event?.target?.closest?.(".card-checkbox")) {
if (event?.target?.closest?.(".card-checkbox")) {
return;
}
const key = `${item.id},${item.url}`;
@@ -1370,22 +1371,26 @@ function changeShowType() {
if (showType.value == "list") {
searchForm.pageSize = 20;
} else {
searchForm.pageSize = 12;
searchForm.pageSize = 20;
}
getDataList();
}
// 获取列表数据
function getDataList(type = null) {
if (showType.value == "list") {
pageSizeOpts.value = [10, 20, 50];
pageSizeOpts.value = [20, 40, 60];
} else {
pageSizeOpts.value = [12, 24, 48];
pageSizeOpts.value = [20, 40, 60];
}
loading.value = true;
getFileListData(searchForm)
.then((res) => {
if (res && res.success && res.result) {
data.value = res.result.records || [];
let records = res.result.records || [];
if (!allowVideo.value) {
records = records.filter(item => !isVideoFile(item));
}
data.value = records;
total.value = res.result.total || 0;
} else {
data.value = [];
@@ -1564,10 +1569,48 @@ onMounted(() => {
// 是组件的话,初始化不调用接口
init();
} else {
searchForm.pageSize = 18; // 页面大小
searchForm.pageSize = 20; // 页面大小: 5列 x 4行 = 20
}
});
// 父组件通过 ref 设置 selectImage 开启选图模式;decorate.vue 还会写 fileType 并调用 changeFileType
defineExpose({ selectImage, fileType, changeFileType });
function formatFileSize(size) {
if (!size || isNaN(size)) return '0 KB';
const num = Number(size);
if (num < 1024) return num + ' B';
if (num < 1024 * 1024) return Math.round(num / 1024) + ' KB';
return (num / (1024 * 1024)).toFixed(1) + ' MB';
}
function getFileFormat(item) {
if (item?.fileType) {
const parts = item.fileType.split('/');
if (parts.length > 1 && parts[1]) {
return parts[1].toUpperCase();
}
}
if (item?.name) {
const ext = item.name.split('.').pop();
if (ext && ext !== item.name) {
return ext.toUpperCase();
}
}
return 'IMG';
}
function getFileDimension(item) {
if (item?.width && item?.height) {
return `${item.width}x${item.height}`;
}
return '未知尺寸';
}
function getFileSubInfo(item) {
const fmt = getFileFormat(item);
const dim = getFileDimension(item);
const size = formatFileSize(item.fileSize);
return `${fmt} · ${dim} · ${size}`;
}
</script>

View File

@@ -64,6 +64,17 @@
<el-form-item label="wap站点地址" prop="staticPageWapAddress">
<el-input v-model="formValidate.staticPageWapAddress" />
</el-form-item>
<el-form-item label="公司名称" prop="companyName">
<el-input style="width:350px;" v-model="formValidate.companyName" />
<span class="desc">页脚展示的公司名称</span>
</el-form-item>
<el-form-item label="公司链接" prop="companyUrl">
<el-input style="width:350px;" v-model="formValidate.companyUrl" />
<span class="desc">页脚公司名称的跳转链接</span>
</el-form-item>
<el-form-item label="官网地址" prop="website">
<el-input style="width:350px;" v-model="formValidate.website" />
</el-form-item>
<div class="label-btns">
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
</div>
@@ -102,12 +113,15 @@ const { proxy } = getCurrentInstance();
const picModelFlag = ref(false);
const formValidate = ref({
buyerSideLogo: "",
companyName: "",
companyUrl: "",
domainLogo: "",
icp: "",
storeSideLogo: "",
siteName: "",
staticPageAddress: "",
staticPageWapAddress: "",
website: "",
});
const selected = ref("");
const ruleValidate = reactive({});