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

40
buyer/pnpm-lock.yaml generated
View File

@@ -36,6 +36,9 @@ importers:
nprogress:
specifier: ^0.2.0
version: 0.2.0
pinia:
specifier: ^4.0.3
version: 4.1.0(@vue/devtools-api@6.6.4)(vue@3.5.43)
psl:
specifier: ^1.8.0
version: 1.15.0
@@ -60,9 +63,6 @@ importers:
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.43)
vuex:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.43)
devDependencies:
'@vue/cli-plugin-babel':
specifier: ^5.0.9
@@ -91,6 +91,9 @@ importers:
less-loader:
specifier: ^11.1.4
version: 11.1.4(less@4.9.1)(webpack@5.111.0(postcss@8.5.28))
vue-eslint-parser:
specifier: ^10.4.1
version: 10.4.1(eslint@9.39.4)
webpack:
specifier: ^5.95.0
version: 5.111.0(cssnano@5.1.15(postcss@8.5.28))(postcss@8.5.28)
@@ -2968,6 +2971,9 @@ packages:
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
nostics@1.3.0:
resolution: {integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==}
npm-run-path@2.0.2:
resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==}
engines: {node: '>=4'}
@@ -3135,6 +3141,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pinia@4.1.0:
resolution: {integrity: sha512-+5/HMuVjYQfKlCD5xQ7zfXn1xUzYFEa7pd0DQK5SaMfZCaFRTHlaH/c02N1FoGOdlzSiwf5EOPgJx//1QDw6gw==}
peerDependencies:
'@vue/devtools-api': ^8.2.1
typescript: '>=5.6.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
@@ -4006,11 +4022,6 @@ packages:
typescript:
optional: true
vuex@4.1.0:
resolution: {integrity: sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==}
peerDependencies:
vue: ^3.2.0
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
@@ -7374,6 +7385,8 @@ snapshots:
normalize-wheel-es@1.2.0: {}
nostics@1.3.0: {}
npm-run-path@2.0.2:
dependencies:
path-key: 2.0.1
@@ -7533,6 +7546,12 @@ snapshots:
picomatch@4.0.7:
optional: true
pinia@4.1.0(@vue/devtools-api@6.6.4)(vue@3.5.43):
dependencies:
'@vue/devtools-api': 6.6.4
nostics: 1.3.0
vue: 3.5.43
pkg-dir@4.2.0:
dependencies:
find-up: 4.1.0
@@ -8449,11 +8468,6 @@ snapshots:
'@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.43
vuex@4.1.0(vue@3.5.43):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.43
watchpack@2.5.2:
dependencies:
graceful-fs: 4.2.11

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,6 +1,16 @@
<template>
<div class="model-form">
<div class="model-content">
<div class="model-form" :style="pageContainerStyle">
<!-- Version 2: 新万米行模型渲染 -->
<div v-if="isVersion2" class="v2-rows-container">
<row-wrapper
v-for="row in v2Rows"
:key="row.id"
:row="row"
/>
</div>
<!-- 旧版降级渲染 -->
<div v-else class="model-content">
<model-form-item
v-for="(element, index) in renderList"
:key="element.key"
@@ -11,14 +21,34 @@
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import ModelFormItem from './ModelFormItem.vue';
import RowWrapper from './RowWrapper.vue';
defineOptions({ name: 'modelForm' });
const props = defineProps(['data']);
// 是否为 v2 行模型数据
const isVersion2 = computed(() => {
return (props.data?.version === 2 || props.data?.version === '2.0') && Array.isArray(props.data?.list);
});
const v2Rows = computed(() => {
return props.data?.list || [];
});
const pageContainerStyle = computed(() => {
if (isVersion2.value && props.data?.pageStyle?.backgroundColor) {
return {
backgroundColor: props.data.pageStyle.backgroundColor
};
}
return {};
});
const renderList = computed(() => {
const list = props.data?.list || [];
return list.filter(
@@ -27,21 +57,26 @@ const renderList = computed(() => {
});
function isBuiltinModule(type) {
// 顶部广告、快捷导航由 Index 页单独渲染
return type === 'topAdvert' || type === 'navBar';
}
</script>
<style lang="scss" scoped>
.model-form {
position: relative;
z-index: 0;
width: 100%;
}
.v2-rows-container {
width: 100%;
min-height: 400px;
}
.model-content {
width: 100%;
min-height: 1200px;
box-sizing: border-box;
/* 勿设 overflow-x: hidden,会裁切 discountAdvert 的 margin-left: -100px 左溢背景 */
overflow-x: visible;
}
</style>

View File

@@ -0,0 +1,325 @@
<template>
<div class="row-wrapper" :style="rowWrapperStyle">
<div class="row-inner-flex" :style="rowInnerFlexStyle">
<div
v-for="comp in row.components"
:key="comp.id"
class="component-cell"
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
>
<component
:is="resolveComponent(comp.type)"
v-if="resolveComponent(comp.type)"
:component="comp"
:options="comp.options || {}"
:component-style="comp.componentStyle || {}"
/>
<div v-else class="unknown-component-placeholder">
未知组件: {{ comp.type }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { resolveComponent } from './registry.js';
const props = defineProps({
row: {
type: Object,
required: true
}
});
const rowWrapperStyle = computed(() => {
const s = props.row.rowStyle || {};
const style = {};
// 宽度 (width)
if (s.width !== undefined && s.width !== '') {
const w = String(s.width).trim();
if (w && w !== '100%') {
const num = parseInt(w, 10);
if (!isNaN(num) && num >= 1920 && !w.endsWith('%')) {
style.width = '100%';
} else {
style.width = /^\d+$/.test(w) ? `${w}px` : w;
style.maxWidth = '100%';
}
} else {
style.width = '100%';
}
} else {
style.width = '100%';
}
// 高度 (height)
if (s.height !== undefined && s.height !== '') {
const h = String(s.height).trim();
if (h && Number(h) > 0) {
style.minHeight = /^\d+$/.test(h) ? `${h}px` : h;
}
}
// 外边距 (margin)
const isNarrow = style.width !== '100%';
if (Array.isArray(s.margin)) {
style.marginTop = `${s.margin[0] || 0}px`;
style.marginRight = s.margin[1] ? `${s.margin[1]}px` : (isNarrow ? 'auto' : '0px');
style.marginBottom = `${s.margin[2] || 0}px`;
style.marginLeft = s.margin[3] ? `${s.margin[3]}px` : (isNarrow ? 'auto' : '0px');
} else if (isNarrow) {
style.marginLeft = 'auto';
style.marginRight = 'auto';
}
// 内边距 (padding)
if (Array.isArray(s.padding)) {
style.paddingTop = `${s.padding[0] || 0}px`;
style.paddingRight = `${s.padding[1] || 0}px`;
style.paddingBottom = `${s.padding[2] || 0}px`;
style.paddingLeft = `${s.padding[3] || 0}px`;
}
// 圆角 (radius)
if (Array.isArray(s.radius)) {
style.borderTopLeftRadius = `${s.radius[0] || 0}px`;
style.borderTopRightRadius = `${s.radius[1] || 0}px`;
style.borderBottomRightRadius = `${s.radius[2] || 0}px`;
style.borderBottomLeftRadius = `${s.radius[3] || 0}px`;
}
// 行背景由各组件配置统一管理
return style;
});
const rowInnerFlexStyle = computed(() => {
const s = props.row.rowStyle || {};
return {
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: s.alignItems || 'stretch',
justifyContent: s.justifyContent || 'flex-start',
width: '100%',
gap: `${s.gap || 0}px`
};
});
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
if (!style) return { flex: '1 1 0%' };
const s = {};
const gap = Number(rowStyle?.gap) || 0;
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;
if (style.widthRatio && style.widthRatio !== 'auto') {
let w = style.widthRatio;
if (w === '16.67%' || w === '16.6%' || w === '16.7%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 6)` : 'calc(100% / 6)';
} else if (w === '66.67%' || w === '66.6%' || w === '66.7%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) * 2 / 3)` : 'calc(100% * 2 / 3)';
} else if (w === '33.33%' || w === '33.3%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 3)` : 'calc(100% / 3)';
} else if (w === '50%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 2)` : '50%';
}
s.flex = `0 0 ${w}`;
s.width = w;
s.maxWidth = w;
s.minWidth = '0';
} else {
s.flex = '1 1 0%';
s.minWidth = '0';
}
if (Array.isArray(style.padding)) {
s.paddingTop = `${style.padding[0] || 0}px`;
s.paddingRight = `${style.padding[1] || 0}px`;
s.paddingBottom = `${style.padding[2] || 0}px`;
s.paddingLeft = `${style.padding[3] || 0}px`;
}
if (Array.isArray(style.radius)) {
s.borderTopLeftRadius = `${style.radius[0] || 0}px`;
s.borderTopRightRadius = `${style.radius[1] || 0}px`;
s.borderBottomRightRadius = `${style.radius[2] || 0}px`;
s.borderBottomLeftRadius = `${style.radius[3] || 0}px`;
}
if (Array.isArray(style.margin)) {
s.marginTop = `${style.margin[0] || 0}px`;
s.marginRight = `${style.margin[1] || 0}px`;
s.marginBottom = `${style.margin[2] || 0}px`;
s.marginLeft = `${style.margin[3] || 0}px`;
}
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
let bg = style.backgroundColor;
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(bg.trim())) {
bg = bg.trim().replace(/,\s*0\s*\)$/, ', 1)');
}
s.backgroundColor = bg;
}
if (style.backgroundImage) {
s.backgroundImage = `url(${style.backgroundImage})`;
s.backgroundSize = 'cover';
s.backgroundRepeat = 'no-repeat';
s.backgroundPosition = 'center';
}
return s;
}
</script>
<style lang="scss" scoped>
.row-wrapper {
box-sizing: border-box;
.row-inner-flex {
box-sizing: border-box;
.component-cell {
box-sizing: border-box;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
}
}
.unknown-component-placeholder {
padding: 20px;
background: #fef2f2;
border: 1px dashed #f87171;
color: #dc2626;
text-align: center;
font-size: 13px;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div class="decor-article-content" :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>
.decor-article-content {
width: 100%;
box-sizing: border-box;
.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;
}
}
}
.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,142 @@
<template>
<div class="decor-brand-wall" :style="customStyle">
<div
class="brand-grid"
:style="{
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<router-link
v-for="(item, idx) in brandList"
:key="idx"
:to="item.url || '/goods'"
class="brand-card"
>
<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>
</router-link>
</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'
};
});
</script>
<style lang="scss" scoped>
.decor-brand-wall {
width: 100%;
box-sizing: border-box;
.brand-grid {
max-width: 1600px;
margin: 0 auto;
display: grid;
width: 100%;
.brand-card {
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;
text-decoration: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
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,193 @@
<template>
<div
class="decor-carousel"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
>
<el-carousel
:autoplay="options.autoPlay !== false"
:interval="Number(options.interval) || 4000"
:arrow="options.showArrow ? 'hover' : 'never'"
:indicator-position="options.showIndicator !== false ? '' : 'none'"
class="custom-carousel"
>
<el-carousel-item v-for="(slide, idx) in slidesList" :key="idx">
<router-link :to="slide.url || '/'" class="slide-link">
<img
:src="slide.img"
:alt="slide.title || 'banner'"
class="slide-img"
/>
</router-link>
</el-carousel-item>
</el-carousel>
</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%');
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' }
];
});
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;
});
</script>
<style lang="scss" scoped>
.decor-carousel {
width: 100%;
aspect-ratio: var(--carousel-ratio, 2.5 / 1);
overflow: hidden;
position: relative;
background: #f8fafc;
box-sizing: border-box;
.custom-carousel {
width: 100%;
height: 100%;
:deep(.el-carousel__container) {
height: 100% !important;
position: relative;
}
:deep(.el-carousel__indicators--horizontal) {
bottom: 14px;
.el-carousel__indicator {
padding: 4px;
.el-carousel__button {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.7);
opacity: 1;
transition: all 0.25s;
}
&.is-active .el-carousel__button {
width: 22px;
border-radius: 5px;
background-color: #ea580c;
}
}
}
.slide-link {
display: block;
width: 100%;
height: 100%;
.slide-img {
width: 100%;
height: 100%;
object-fit: var(--carousel-fit, contain);
display: block;
}
}
}
&.is-compact {
:deep(.el-carousel__arrow) {
width: 26px;
height: 26px;
font-size: 12px;
}
:deep(.el-carousel__indicators--horizontal) {
bottom: 8px;
.el-carousel__indicator {
padding: 2px;
.el-carousel__button {
width: 6px;
height: 6px;
}
&.is-active .el-carousel__button {
width: 14px;
}
}
}
}
&.is-mini {
:deep(.el-carousel__arrow) {
width: 20px;
height: 20px;
font-size: 10px;
}
:deep(.el-carousel__indicators--horizontal) {
bottom: 4px;
.el-carousel__indicator {
padding: 1px;
.el-carousel__button {
width: 4px;
height: 4px;
}
&.is-active .el-carousel__button {
width: 10px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="decor-category-mega-menu" :style="customStyle">
<!-- 顶部标题:分类(同万米设计,纯净大方) -->
<div class="menu-header">
<span class="header-title">{{ options.title || '分类' }}</span>
</div>
<!-- 垂直一级类目列表(8项均匀铺满整个高度) -->
<div class="category-list">
<router-link
v-for="(cat, idx) in categoriesList"
:key="idx"
:to="cat.url || `/goodsList?categoryId=${cat.id || ''}`"
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>
</router-link>
</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>
.decor-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;
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;
text-decoration: none;
transition: all 0.15s ease;
.cat-name {
font-size: var(--font-size, 13px);
color: var(--text-color, #334155);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arrow-icon {
font-size: 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,199 @@
<template>
<div class="decor-category-nav" :style="customStyle">
<div class="nav-inner">
<!-- 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. 分类导航项水平列表 -->
<ul class="nav-items-ul">
<li
v-for="(item, idx) in options.navItems"
:key="idx"
class="nav-li"
>
<router-link
:to="item.url || '/'"
class="nav-link"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#e11d48'
}"
active-class="is-active"
>
<img v-if="item.img" :src="item.img" class="cat-icon" alt="cat-icon" />
<span class="cat-title">{{ item.title }}</span>
<span
class="badge"
v-if="item.badge"
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
>
{{ item.badge }}
</span>
</router-link>
</li>
</ul>
</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`
};
});
</script>
<style lang="scss" scoped>
.decor-category-nav {
width: 100%;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
.nav-inner {
max-width: 1600px;
width: 100%;
margin: 0 auto;
padding: 0 16px;
height: 100%;
display: flex;
align-items: center;
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;
user-select: none;
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-ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
align-items: center;
gap: 28px;
height: 100%;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.nav-li {
height: 100%;
display: flex;
align-items: center;
.nav-link {
position: relative;
text-decoration: none;
font-size: 14px;
font-weight: 500;
color: var(--text-color, #334155);
display: inline-flex;
align-items: center;
gap: 6px;
height: 100%;
white-space: nowrap;
transition: color 0.2s;
.cat-icon {
width: 22px;
height: 22px;
object-fit: contain;
border-radius: 4px;
transition: transform 0.2s ease;
}
&:hover, &.is-active {
color: var(--active-color, #e11d48) !important;
.cat-icon {
transform: scale(1.12);
}
}
.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);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,559 @@
<template>
<div class="decor-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>
<h3 class="title-text">{{ options.title }}</h3>
<span class="subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<router-link :to="options.heroHref || '/goods'" class="header-more">
探索更多场景 &gt;
</router-link>
</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" />
<router-link :to="options.heroHref || '/goods'" 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>
</router-link>
</div>
<!-- 专属活动入口 (Promos) -->
<div class="hero-promos-wrap" v-if="promosList.length">
<router-link
v-for="(p, pIdx) in promosList"
:key="pIdx"
:to="p.url || '/goods'"
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>
</router-link>
</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"
>
<router-link :to="`/goods/${g.id || 1}`" class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
<span class="scene-tag">场景精选</span>
</router-link>
<div class="card-info">
<h4 class="goods-name">
<router-link :to="`/goods/${g.id || 1}`">{{ g.goodsName }}</router-link>
</h4>
<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="立即加购"
@click="addToCart(g)"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
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;
});
const promosList = computed(() => {
if (Array.isArray(props.options?.promos) && props.options.promos.length > 0) {
return props.options.promos;
}
return [
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时', url: '/goods' },
{ title: '新品驾到', desc: '官方正品首发', tag: '新品', url: '/goods' }
];
});
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'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-channel-floor {
width: 100%;
box-sizing: border-box;
.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;
margin: 0;
}
.subtitle-text {
font-size: 13px;
color: #94a3b8;
margin-left: 2px;
}
}
.header-more {
font-size: 13px;
color: #64748b;
text-decoration: none;
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;
text-decoration: none;
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;
text-decoration: none;
color: inherit;
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 {
display: block;
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;
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: 0 0 6px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #4f46e5;
}
}
}
.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,277 @@
<template>
<div class="decor-channel-grid" :style="customStyle">
<div
class="grid-container"
:style="{
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<router-link
v-for="(item, idx) in options.items"
:key="idx"
:to="item.url || '/goods'"
class="channel-card"
:class="[
`is-${layoutMode}`,
{ 'is-compact': isCompact }
]"
>
<!-- 图标/图片 -->
<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-info">
<div class="card-title">{{ item.title || '频道名称' }}</div>
<div class="card-sub">{{ item.subtitle || '副标题文案' }}</div>
</div>
</router-link>
</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'
};
});
</script>
<style lang="scss" scoped>
.decor-channel-grid {
width: 100%;
box-sizing: border-box;
.grid-container {
max-width: 1600px;
margin: 0 auto;
display: grid;
width: 100%;
box-sizing: border-box;
.channel-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
text-decoration: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
box-sizing: border-box;
transition: all 0.25s 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-info .card-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-info {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
.card-title {
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s;
}
.card-sub {
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-info {
flex: 1;
gap: 3px;
text-align: left;
.card-title {
font-size: 14px;
line-height: 1.3;
}
.card-sub {
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-info {
width: 100%;
gap: 2px;
text-align: center;
.card-title {
font-size: 13px;
line-height: 1.3;
}
.card-sub {
font-size: 12px;
line-height: 1.2;
}
}
}
// 3. 六分之一紧凑模式
&.is-compact {
padding: 10px 6px 8px 6px;
gap: 5px;
.card-thumb {
width: 36px;
height: 36px;
}
.card-info {
gap: 1px;
.card-title {
font-size: 12px;
}
.card-sub {
font-size: 11px;
transform: scale(0.95);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div class="decor-coupon-list" :class="`skin-${options.skin || 'red'}`" :style="customStyle">
<div
class="coupon-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(cp, idx) in coupons"
:key="idx"
class="coupon-card"
>
<div class="card-left">
<div class="amount-wrap">
<span class="symbol">¥</span>
<span class="amount">{{ cp.price }}</span>
</div>
<span class="threshold">{{ cp.consumeThreshold ? `满 ${cp.consumeThreshold} 可用` : '无门槛' }}</span>
</div>
<div class="card-right">
<span class="scope">{{ cp.title }}</span>
<button type="button" class="claim-btn" @click="handleClaim(cp)">
立即领取
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
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 coupons = 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);
});
function handleClaim(cp) {
ElMessage.success(`领取成功!¥${cp.price} 优惠券已放入您的卡券包`);
}
</script>
<style lang="scss" scoped>
.decor-coupon-list {
width: 100%;
.coupon-grid {
display: grid;
gap: 16px;
.coupon-card {
display: flex;
height: 90px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
.card-left {
width: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ffffff;
padding: 8px;
.amount-wrap {
display: flex;
align-items: baseline;
.symbol { font-size: 14px; font-weight: 700; }
.amount { font-size: 28px; font-weight: 800; }
}
.threshold {
font-size: 11px;
opacity: 0.9;
}
}
.card-right {
flex: 1;
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 12px 14px;
.scope {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.claim-btn {
align-self: flex-start;
padding: 4px 12px;
border-radius: 14px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
&:hover { opacity: 0.85; }
}
}
}
}
&.skin-red {
.coupon-card .card-left { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.claim-btn { background: #ffe4e6; color: #e11d48; }
}
&.skin-dark {
.coupon-card .card-left { background: linear-gradient(135deg, #334155, #0f172a); }
.claim-btn { background: #f1f5f9; color: #0f172a; }
}
&.skin-gradient {
.coupon-card .card-left { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.claim-btn { background: #fdf2f8; color: #db2777; }
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="decor-footer-nav" :style="customStyle">
<div class="footer-inner">
<!-- 1. 服务保障条 -->
<div class="assurance-row" v-if="options.showAssurance && options.assurances?.length">
<div
v-for="(item, idx) in options.assurances"
:key="idx"
class="assurance-col"
>
<div class="code-badge">{{ item.code }}</div>
<div class="text-info">
<span class="main-t">{{ item.title }}</span>
<span class="sub-t">{{ item.desc }}</span>
</div>
</div>
</div>
<!-- 2. 服务指南与链接列 -->
<div class="guides-row" v-if="options.guideGroups?.length">
<div
v-for="(group, gIdx) in options.guideGroups"
:key="gIdx"
class="guide-col"
>
<h4 class="col-title">{{ group.title }}</h4>
<ul class="col-links">
<li v-for="(link, lIdx) in group.links" :key="lIdx">
<router-link :to="link.url || '/article'">{{ link.text }}</router-link>
</li>
</ul>
</div>
</div>
<!-- 3. 版权与备案 -->
<div class="copyright-row">
<p class="icp-text" v-if="options.icpText">{{ options.icpText }}</p>
<p class="copy-text">{{ options.copyright || 'Copyright © 2026 Lilishop. All Rights Reserved.' }}</p>
</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 {
backgroundColor: s.backgroundColor || '#f1f5f9'
};
});
</script>
<style lang="scss" scoped>
.decor-footer-nav {
width: 100%;
padding: 40px 0 30px;
border-top: 1px solid #e2e8f0;
.footer-inner {
max-width: 1600px;
margin: 0 auto;
padding: 0 20px;
.assurance-row {
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: 30px;
border-bottom: 1px solid #e2e8f0;
.assurance-col {
display: flex;
align-items: center;
gap: 12px;
.code-badge {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid #e11d48;
color: #e11d48;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.text-info {
display: flex;
flex-direction: column;
.main-t {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.sub-t {
font-size: 13px;
color: #94a3b8;
}
}
}
}
.guides-row {
display: flex;
justify-content: space-around;
padding: 30px 0;
border-bottom: 1px solid #e2e8f0;
.guide-col {
.col-title {
font-size: 15px;
font-weight: 600;
color: #1e293b;
margin-bottom: 14px;
}
.col-links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
li a {
color: #64748b;
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
}
}
.copyright-row {
text-align: center;
padding-top: 24px;
font-size: 12px;
color: #94a3b8;
line-height: 1.8;
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="decor-free-layout" :style="customStyle">
<div
v-for="(el, idx) in options.elements"
:key="idx"
class="free-element"
:style="{
top: `${el.top || 0}px`,
left: `${el.left || 0}px`,
width: el.width ? `${el.width}px` : 'auto',
height: el.height ? `${el.height}px` : 'auto'
}"
>
<img v-if="el.type === 'image'" :src="el.src" alt="" style="width:100%;height:100%;object-fit:cover;" />
<span v-else-if="el.type === 'text'">{{ el.text }}</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 || 320}px`,
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`
: '0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
</script>
<style lang="scss" scoped>
.decor-free-layout {
position: relative;
width: 100%;
overflow: hidden;
box-sizing: border-box;
.free-element {
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="decor-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>
<router-link :to="options.moreUrl || '/goods'" class="grid-more" v-if="options.showMoreLink !== false">
{{ options.moreText || '查看全部' }} &gt;
</router-link>
</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>
<router-link :to="`/goods/${item.id || 1}`" class="cell-thumb-box">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" class="cell-thumb" />
</router-link>
<div class="cell-body">
<h4 class="cell-name">
<router-link :to="`/goods/${item.id || 1}`">{{ item.goodsName }}</router-link>
</h4>
<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="立即选购" @click="addToCart(item)">+</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
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;
});
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'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-goods-grid {
width: 100%;
box-sizing: border-box;
.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;
text-decoration: none;
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 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
margin-bottom: 12px;
text-decoration: none;
.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;
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: 0 0 6px 0;
a {
color: #334155;
text-decoration: none;
&:hover {
color: #f97316;
}
}
}
.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,327 @@
<template>
<div class="decor-goods-list" :style="customStyle">
<!-- 标题栏 -->
<div class="floor-title-bar" v-if="options.showTitle !== false && options.title">
<div class="title-left">
<h3 class="main-title">{{ options.title }}</h3>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<router-link :to="options.moreUrl || '/goods'" class="more-link" v-if="options.showMoreLink !== false">
{{ options.moreText || '查看更多' }} &gt;
</router-link>
</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"
>
<router-link :to="`/goods/${g.id || 1}`" 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>
</router-link>
<div class="card-body">
<h4 class="goods-name" v-if="options.showTitle !== false">
<router-link :to="`/goods/${g.id || 1}`">{{ g.goodsName }}</router-link>
</h4>
<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="加入购物车"
@click="addToCart(g)"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
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(() => {
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);
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-goods-list {
width: 100%;
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;
margin: 0;
}
.sub-title {
font-size: 13px;
color: #94a3b8;
}
}
.more-link {
font-size: 13px;
color: #64748b;
text-decoration: none;
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 {
display: block;
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;
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: 0 0 4px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #e11d48;
}
}
}
.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,553 @@
<template>
<div class="decor-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>
<h3 class="floor-title-text">{{ options.title || '潮流数码' }}</h3>
<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">
<router-link
v-for="(tag, idx) in subCategories"
:key="idx"
to="/goods"
class="tag-item"
:class="{ active: activeTagIdx === idx }"
@click.prevent="activeTagIdx = idx"
>
{{ tag }}
</router-link>
</div>
<router-link :to="options.moreUrl || '/goods'" class="more-link">
查看更多 &gt;
</router-link>
</div>
</div>
<!-- 楼层主体:左侧竖向营销海报 + 右侧商品网格 -->
<div class="floor-main-body" :class="{ 'no-poster': !showPoster }">
<!-- 左侧营销大促海报 -->
<div class="floor-left-poster" v-if="showPoster">
<router-link :to="options.posterUrl || '/goods'" class="poster-link">
<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>
</router-link>
</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"
>
<router-link :to="`/goods/${g.id || 1}`" 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>
</router-link>
<div class="card-detail">
<h4 class="goods-name-line">
<router-link :to="`/goods/${g.id || 1}`">{{ g.goodsName }}</router-link>
</h4>
<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="加入购物车"
@click="addToCart(g)"
>
<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';
import { ElMessage } from 'element-plus';
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;
});
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'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-grid-floor {
width: 100%;
box-sizing: border-box;
.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;
margin: 0;
}
.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;
text-decoration: none;
transition: all 0.2s;
&:hover, &.active {
color: #2563eb;
background: #eff6ff;
font-weight: 600;
}
}
}
.more-link {
font-size: 13px;
color: #64748b;
text-decoration: none;
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-link {
display: block;
position: relative;
width: 100%;
height: 100%;
min-height: 520px;
text-decoration: none;
.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;
&: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 {
display: block;
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;
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: 0 0 6px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #2563eb;
}
}
}
.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,101 @@
<template>
<div class="decor-hot-zone" :style="customStyle">
<div class="hot-zone-wrap" v-if="displayImageUrl">
<img :src="displayImageUrl" alt="hotzone" class="base-image" />
<router-link
v-for="(zone, idx) in displayZones"
:key="idx"
:to="zone.url || '/'"
class="zone-link"
:style="{
top: `${zone.top || 0}%`,
left: `${zone.left || 0}%`,
width: `${zone.width || 10}%`,
height: `${zone.height || 10}%`
}"
:title="zone.title"
/>
</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>
.decor-hot-zone {
width: 100%;
box-sizing: border-box;
.hot-zone-wrap {
position: relative;
width: 100%;
max-width: 1600px;
margin: 0 auto;
border-radius: inherit;
overflow: hidden;
.base-image {
width: 100%;
display: block;
border-radius: inherit;
object-fit: cover;
}
.zone-link {
position: absolute;
display: block;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
}
}
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div
class="decor-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>
<router-link :to="item.url || item.href || '/article'" class="notice-item-link">
{{ item.title || item.text }}
</router-link>
<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">
<router-link :to="options.moreUrl || '/article'" class="more-link">
{{ options.moreText || '更多' }} &gt;
</router-link>
</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>
.decor-notice-bar {
width: 100%;
height: 44px;
display: flex;
align-items: center;
box-sizing: border-box;
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-link {
flex: 1;
font-size: 13px;
color: #334155;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
transition: color 0.2s;
&:hover {
color: #ef4444;
}
}
.notice-item-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
flex-shrink: 0;
margin-right: 4px;
}
}
}
}
.notice-more-action {
flex-shrink: 0;
.more-link {
font-size: 12px;
color: #64748b;
text-decoration: none;
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-link {
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-link {
font-size: 11px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="decor-promo-group" :style="customStyle">
<div
class="promo-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<router-link
v-for="(card, idx) in cardsList"
:key="idx"
:to="card.url || '/goods'"
class="promo-card"
:style="card.bgImg ? { backgroundImage: `url(${card.bgImg})` } : {}"
>
<div class="card-inner">
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-sub">{{ card.subtitle }}</p>
<span class="action-btn">立即查看 ></span>
</div>
</router-link>
</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>
.decor-promo-group {
width: 100%;
box-sizing: border-box;
.promo-grid {
display: grid;
gap: 16px;
.promo-card {
min-height: 160px;
padding: 24px;
background-color: #1e293b;
background-size: cover;
background-position: center;
border-radius: 12px;
text-decoration: none;
display: flex;
flex-direction: column;
justify-content: center;
color: #ffffff;
transition: all 0.25s ease;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
&:hover {
transform: translateY(-3px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.15);
}
.card-inner {
.card-title {
font-size: 22px;
font-weight: 800;
color: #ffffff;
margin: 0;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.card-sub {
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);
}
.action-btn {
display: inline-block;
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,572 @@
<template>
<div class="decor-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>
<router-link :to="`/goods/${featuredGoods.id || 1}`" class="big-card-thumb">
<img :src="featuredGoods.thumbnail || defaultThumb" :alt="featuredGoods.goodsName" />
</router-link>
<div class="big-card-info">
<h4 class="big-title">
<router-link :to="`/goods/${featuredGoods.id || 1}`">{{ featuredGoods.goodsName }}</router-link>
</h4>
<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" @click="addToCart(featuredGoods)">立即入手 &gt;</button>
</div>
</div>
</div>
<!-- 右侧与后续标准卡片网格 -->
<div class="featured-sub-grid">
<div
v-for="(item, idx) in regularGoods"
:key="item.id || idx"
class="recommend-card"
>
<router-link :to="`/goods/${item.id || 1}`" class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">推荐</span>
</router-link>
<div class="card-body">
<h4 class="goods-title">
<router-link :to="`/goods/${item.id || 1}`">{{ item.goodsName }}</router-link>
</h4>
<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" @click="addToCart(item)">+</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"
>
<router-link :to="`/goods/${item.id || 1}`" class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">为你推荐</span>
</router-link>
<div class="card-body">
<h4 class="goods-title">
<router-link :to="`/goods/${item.id || 1}`">{{ item.goodsName }}</router-link>
</h4>
<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" @click="addToCart(item)">+</button>
</div>
</div>
</div>
</div>
<!-- 底部换一批 / 探索更多推荐 -->
<div class="recommend-footer-action">
<button type="button" class="refresh-rec-btn" @click="handleRefresh">
<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';
import { ElMessage } from 'element-plus';
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'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
function handleRefresh() {
ElMessage.info('已刷新为您推荐的专属商品');
}
</script>
<style lang="scss" scoped>
.decor-recommend-feed {
width: 100%;
box-sizing: border-box;
.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 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 6px;
margin-bottom: 12px;
background: #f8fafc;
text-decoration: none;
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;
line-height: 1.4;
margin: 0 0 6px 0;
a {
color: #0f172a;
text-decoration: none;
&:hover {
color: #ef4444;
}
}
}
.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 {
display: block;
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;
line-height: 1.4;
height: 34px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 4px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #ef4444;
}
}
}
.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,217 @@
<template>
<div class="decor-search-header" :style="customStyle">
<div class="header-inner">
<!-- 商城 Logo -->
<div class="logo-area">
<router-link to="/">
<img
v-if="options.logoUrl"
:src="options.logoUrl"
alt="Logo"
class="site-logo"
/>
<span v-else class="text-logo">LiliShop</span>
</router-link>
</div>
<!-- 搜索主区域 -->
<div class="search-main">
<div class="search-input-box">
<el-input
v-model="keyword"
size="large"
:placeholder="options.placeholder || '搜索品牌或商品'"
@keyup.enter="handleSearch"
class="input-control"
/>
<button type="button" class="search-btn" @click="handleSearch">
<el-icon :size="18"><Search /></el-icon>
<span>搜索</span>
</button>
</div>
<!-- 热门推荐词 -->
<div class="hot-words" v-if="options.showHotWords && options.hotWords?.length">
<span
v-for="(word, idx) in options.hotWords"
:key="idx"
class="hot-word"
@click="quickSearch(word)"
>
{{ word }}
</span>
</div>
</div>
<!-- 购物车入口 -->
<div class="cart-entry" v-if="options.showCart">
<router-link to="/cart" class="cart-btn">
<el-icon :size="20" class="cart-icon"><ShoppingCart /></el-icon>
<span>我的购物车</span>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { Search, ShoppingCart } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const router = useRouter();
const keyword = ref('');
function handleSearch() {
const kw = keyword.value.trim();
if (kw) {
router.push({ path: '/goodsList', query: { keyword: kw } });
}
}
function quickSearch(word) {
keyword.value = word;
handleSearch();
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
minHeight: `${s.height || 90}px`
};
});
</script>
<style lang="scss" scoped>
.decor-search-header {
width: 100%;
display: flex;
align-items: center;
.header-inner {
max-width: 1600px;
width: 100%;
margin: 0 auto;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 40px;
.logo-area {
flex-shrink: 0;
.site-logo {
max-height: 52px;
max-width: 180px;
object-fit: contain;
}
.text-logo {
font-size: 26px;
font-weight: 800;
color: #e11d48;
letter-spacing: -0.5px;
}
}
.search-main {
flex: 1;
max-width: 600px;
.search-input-box {
display: flex;
align-items: center;
:deep(.el-input__wrapper) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-color: #e11d48;
box-shadow: 0 0 0 1px #e11d48 inset !important;
}
.search-btn {
height: 40px;
padding: 0 24px;
background: #e11d48;
color: #ffffff;
border: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #be123c;
}
}
}
.hot-words {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
.hot-word {
color: #94a3b8;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
}
.cart-entry {
flex-shrink: 0;
.cart-btn {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 18px;
border: 1px solid #e2e8f0;
border-radius: 4px;
color: #334155;
text-decoration: none;
font-size: 13px;
font-weight: 500;
background: #f8fafc;
transition: all 0.2s;
&:hover {
border-color: #e11d48;
color: #e11d48;
background: #fff1f2;
}
.cart-icon {
color: #e11d48;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div
class="decor-title-bar"
:class="[
`align-${options.align || 'left'}`,
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
>
<div class="title-wrap">
<h2 class="main-title">{{ options.mainTitle || '楼层标题' }}</h2>
<p class="subtitle" v-if="options.subtitle">{{ options.subtitle }}</p>
</div>
<router-link :to="options.moreUrl || '/goods'" class="more-link" v-if="options.showMore">
{{ options.moreText || '查看更多 >' }}
</router-link>
</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>
.decor-title-bar {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
box-sizing: border-box;
gap: 12px;
&.align-center {
justify-content: center;
text-align: center;
position: relative;
.more-link {
position: absolute;
right: 0;
bottom: 0;
}
}
.title-wrap {
min-width: 0;
.main-title {
font-size: 26px;
font-weight: 800;
color: #0f172a;
margin: 0;
line-height: 1.2;
word-break: break-word;
}
.subtitle {
font-size: 14px;
color: #94a3b8;
margin: 6px 0 0 0;
word-break: break-word;
}
}
.more-link {
font-size: 13px;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
white-space: nowrap;
flex-shrink: 0;
&:hover {
color: #e11d48;
}
}
&.is-compact {
.title-wrap {
.main-title {
font-size: 20px;
}
.subtitle {
font-size: 13px;
margin-top: 4px;
}
}
.more-link {
font-size: 12px;
}
}
&.is-mini {
flex-direction: column;
align-items: flex-start;
gap: 6px;
&.align-center {
align-items: center;
text-align: center;
.more-link {
position: static;
margin-top: 4px;
}
}
.title-wrap {
.main-title {
font-size: 16px;
}
.subtitle {
font-size: 12px;
margin-top: 2px;
}
}
.more-link {
font-size: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="decor-top-bar" :style="customStyle">
<div class="top-bar-inner">
<div class="bar-left">
<span class="welcome-txt">{{ options.welcomeText || '欢迎来到商城' }}</span>
<span class="auth-links" v-if="!username">
<router-link :to="options.loginUrl || '/login'" class="auth-item">
[{{ options.loginText || '登录' }}]
</router-link>
<router-link :to="options.registerUrl || '/signUp'" class="auth-item">
[{{ options.registerText || '免费注册' }}]
</router-link>
</span>
<span class="auth-links user-logged" v-else>
<router-link :to="options.userCenterUrl || '/home/MyOrder'" class="user-name">
欢迎,{{ username }}
</router-link>
<span class="sep">|</span>
<a class="auth-item" @click="handleLogout">{{ options.logoutText || '退出' }}</a>
</span>
</div>
<div class="bar-right">
<router-link
v-for="(menu, idx) in (options.rightMenus || defaultMenus)"
:key="idx"
:to="menu.url || '/'"
class="bar-link"
>
{{ menu.title }}
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import Cookies from 'js-cookie';
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 username = computed(() => {
const userInfo = Cookies.get('userInfo');
if (userInfo) {
try {
const u = JSON.parse(userInfo);
return u.nickName || u.username || '';
} catch (e) {
return '';
}
}
return '';
});
function handleLogout() {
Cookies.remove('userInfo');
Cookies.remove('accessToken');
window.location.reload();
}
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>
.decor-top-bar {
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-size: 12px;
overflow: hidden;
*, *::before, *::after {
box-sizing: border-box;
}
.top-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;
white-space: nowrap;
.welcome-txt {
white-space: nowrap;
}
.auth-links {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
.auth-item {
color: #ff6a00;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.sep {
margin: 0 4px;
color: #dcdfe6;
}
.user-name {
color: #333333;
font-weight: 500;
text-decoration: none;
&:hover {
color: #ff6a00;
}
}
}
}
.bar-right {
display: flex;
align-items: center;
gap: 18px;
white-space: nowrap;
.bar-link {
color: inherit;
text-decoration: none;
transition: color 0.2s;
white-space: nowrap;
&:hover {
color: #ff6a00;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div class="decor-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">
<router-link :to="options.loginUrl || '/login'" class="action-btn login-btn">
{{ options.loginText || '登录' }}
</router-link>
<router-link :to="options.registerUrl || '/signUp'" class="action-btn register-btn">
{{ options.registerText || '注册' }}
</router-link>
</div>
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<router-link :to="options.cartUrl || '/cart'" class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</router-link>
<router-link :to="options.couponUrl || '/member/coupon'" class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</router-link>
<router-link :to="options.orderUrl || '/member/order'" class="metric-card">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</router-link>
</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>
<router-link to="/article" class="news-more">查看全部 &gt;</router-link>
</div>
<div class="news-list">
<router-link to="/article" class="news-item" title="精选优质好物 官方正品保障">
<span class="dot">·</span>
<span class="news-text">精选优质好物 官方正品保障</span>
</router-link>
<router-link to="/article" class="news-item" title="双11狂欢 跨店每满300减50">
<span class="dot">·</span>
<span class="news-text">双11狂欢 跨店每满300减50</span>
</router-link>
<router-link to="/article" class="news-item" title="新人专享好礼 注册立领大礼包">
<span class="dot">·</span>
<span class="news-text">新人专享好礼 注册立领大礼包</span>
</router-link>
</div>
</div>
<!-- 6. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<router-link to="/member/favorites" class="service-item">
<el-icon class="service-icon"><Star /></el-icon>
<span class="service-name">收藏</span>
</router-link>
<router-link to="/member/footprint" class="service-item">
<el-icon class="service-icon"><Clock /></el-icon>
<span class="service-name">足迹</span>
</router-link>
<router-link to="/couponCenter" class="service-item">
<el-icon class="service-icon"><Present /></el-icon>
<span class="service-name">红包</span>
</router-link>
<router-link to="/article" class="service-item">
<el-icon class="service-icon"><Service /></el-icon>
<span class="service-name">客服</span>
</router-link>
</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>
.decor-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;
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;
text-decoration: none;
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;
text-decoration: none;
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;
text-decoration: none;
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;
text-decoration: none;
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;
text-decoration: none;
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,114 @@
<template>
<div class="decor-video-player" :style="customStyle">
<video
v-if="options.videoUrl"
:src="options.videoUrl"
:poster="options.coverUrl"
:autoplay="options.autoPlay"
:loop="options.loop"
:controls="options.controls !== false"
:muted="options.autoPlay"
:style="{ height: `${options.height || 450}px` }"
class="video-element"
/>
<div
v-else
class="video-placeholder"
:style="{
height: `${options.height || 450}px`,
backgroundImage: `url(${options.coverUrl || '/static/video/video_cover.png'})`
}"
>
<div class="video-play-hint">
<span class="play-icon">▶</span>
<span class="hint-text">官方品牌宣传片</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
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>
.decor-video-player {
width: 100%;
overflow: hidden;
box-sizing: border-box;
.video-element {
width: 100%;
object-fit: cover;
display: block;
border-radius: inherit;
}
.video-placeholder {
width: 100%;
background: #0f172a;
background-size: cover;
background-position: center;
border-radius: inherit;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 14px;
position: relative;
.video-play-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 24px;
background: rgba(15, 23, 42, 0.7);
border-radius: 12px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.15);
.play-icon {
width: 48px;
height: 48px;
background: #e11d48;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
padding-left: 3px;
box-shadow: 0 4px 14px rgba(225, 29, 72, 0.4);
}
.hint-text {
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
/**
* PC 前台装修楼层组件注册表
* 映射 6 大分类、24 个组件
*/
import TopBar from './components/TopBar.vue';
import SearchHeader from './components/SearchHeader.vue';
import FooterNav from './components/FooterNav.vue';
import CategoryNav from './components/CategoryNav.vue';
import ChannelGrid from './components/ChannelGrid.vue';
import CategoryMegaMenu from './components/CategoryMegaMenu.vue';
import Carousel from './components/Carousel.vue';
import BrandWall from './components/BrandWall.vue';
import HotZone from './components/HotZone.vue';
import FreeLayout from './components/FreeLayout.vue';
import PromoGroup from './components/PromoGroup.vue';
import VideoPlayer from './components/VideoPlayer.vue';
import GoodsList from './components/GoodsList.vue';
import GridFloor from './components/GridFloor.vue';
import GoodsGrid from './components/GoodsGrid.vue';
import ChannelFloor from './components/ChannelFloor.vue';
import RecommendFeed from './components/RecommendFeed.vue';
import CouponList from './components/CouponList.vue';
import UserBenefits from './components/UserBenefits.vue';
import TitleBar from './components/TitleBar.vue';
import ArticleContent from './components/ArticleContent.vue';
import NoticeBar from './components/NoticeBar.vue';
export const COMPONENT_MAP = {
// 1. 结构 (3)
topBar: TopBar,
searchHeader: SearchHeader,
footerNav: FooterNav,
// 2. 导航 (3)
categoryNav: CategoryNav,
channelGrid: ChannelGrid,
categoryMegaMenu: CategoryMegaMenu,
// 3. 广告 (6)
carousel: Carousel,
brandWall: BrandWall,
hotZone: HotZone,
freeLayout: FreeLayout,
promoGroup: PromoGroup,
video: VideoPlayer,
// 4. 商品 (8)
goodsList: GoodsList,
gridFloor: GridFloor,
goodsGrid: GoodsGrid,
goodsCard: GoodsList,
bannerFloor: GridFloor,
goodsBar: GoodsList,
channelFloor: ChannelFloor,
recommendFeed: RecommendFeed,
// 5. 营销 (1)
couponList: CouponList,
userBenefits: UserBenefits,
// 6. 内容 (3)
titleBar: TitleBar,
articleContent: ArticleContent,
noticeBar: NoticeBar
};
/**
* 根据组件 type 解析 Vue 组件
*/
export function resolveComponent(type) {
if (!type) return null;
return COMPONENT_MAP[type] || null;
}

View File

@@ -345,7 +345,6 @@ getGoodsList();
}
}
}
.goods-show-seller {
// padding:3px 0;

View File

@@ -24,31 +24,32 @@
<!-- 固定头部 -->
<hover-search class="hover-search" :class="{show: topSearchShow}"></hover-search>
<!-- 顶部广告 -->
<FixedTopPage :data="topAdvert"></FixedTopPage>
<FixedTopPage :data="topAdvert" v-if="!isVersion2"></FixedTopPage>
<!-- 头部 包括登录,我的订单等 -->
<BaseHeader></BaseHeader>
<BaseHeader v-if="!hasComponentInV2('topBar')"></BaseHeader>
<!-- 搜索框、logo -->
<Search></Search>
<Search v-if="!hasComponentInV2('searchHeader')"></Search>
<div class="index-main">
<!-- 商品分类 + 楼层装修(同一宽度容器,保证与分类栏左缘对齐) -->
<cateNav :showAlways="true" v-if="showNav" :large="carouselLarge" :opacity="carouselOpacity"></cateNav>
<cateNav :showAlways="true" v-if="!isVersion2 && showNav" :large="carouselLarge" :opacity="carouselOpacity"></cateNav>
<model-form :data="modelFormData"></model-form>
</div>
<!-- 底部栏 -->
<BaseFooter></BaseFooter>
<BaseFooter v-if="!hasComponentInV2('footerNav')"></BaseFooter>
<!-- 侧边栏 -->
<fixedBar class="fixed-bar" :class="{'show-fixed': topSearchShow}"></fixedBar>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { onMounted, onUnmounted, ref, computed } from "vue";
import Search from '@/components/Search';
import ModelForm from '@/components/indexDecorate/ModelForm';
import HoverSearch from '@/components/header/hoverSearch';
import fixedBar from '@/components/fixed/index';
import storage from "@/plugins/storage";
import { indexData, getAutoCoup } from '@/api/index.js';
import { DOUBLE11_PAGE_DOCUMENT } from '@/components/indexDecorate/presets/double11Preset.js';
import { seckillByDay } from '@/api/promotion';
defineOptions({ name: "Index" });
@@ -69,6 +70,16 @@ const topSearchShow = ref(false); // 滚动后顶部搜索栏展示
const carouselLarge = ref(false); // 不同轮播分类尺寸
const carouselOpacity = ref(false); // 不同轮播分类样式
const isVersion2 = computed(() => {
return modelFormData.value?.version === 2 || modelFormData.value?.version === '2.0';
});
const hasComponentInV2 = (type) => {
if (!isVersion2.value || !Array.isArray(modelFormData.value?.list)) return false;
return modelFormData.value.list.some(r => r.components && r.components.some(c => c.type === type));
};
// 优惠券可用范围
function useScope(type, storeName) {
let shop = '平台';
@@ -137,7 +148,7 @@ function handleReachBottom() {
function getIndexData() {
if (props.pageData) {
parsePageData(JSON.stringify(props.pageData)).catch(() => {
// 装修数据解析失败时保持默认空楼层
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
});
} else {
// 获取首页装修数据
@@ -146,11 +157,15 @@ function getIndexData() {
try {
await parsePageData(res.result.pageData);
} catch {
// 装修数据解析失败时保持默认空楼层
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}
} else {
// 无服务端数据时默认展示双11平台首页
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}
}).catch((err) => {
console.warn('获取首页装修数据失败', err);
console.warn('获取首页装修数据失败,启用双11平台首页预设', err);
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
});
}
}
@@ -160,6 +175,10 @@ async function parsePageData(pageData) {
if (!dataJson?.list?.length) {
return;
}
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelFormData.value = dataJson;
return;
}
// 秒杀活动不是装修的数据,需要调用接口判断是否有秒杀商品
// 轮播图根据不同轮播,样式不同
for (let i = 0; i < dataJson.list.length; i++) {

View File

@@ -3,7 +3,7 @@ var BASE = {
* @description api请求基础路径
*/
API_DEV: {
manager: "https://admin-api.pickmall.cn",
manager: "http://127.0.0.1:8887",
},
API_PROD: {
manager: "https://admin-api.pickmall.cn"

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More