feat: 新增多个组件并优化优惠券相关功能

- 新增 AppLinkSelectDialog 组件,用于选择 APP 链接- 新增 NavigationBarCellProperty组件,用于导航栏单元格属性设置
- 新增 CombinationShowcase 和 CombinationTableSelect 组件,用于拼团活动展示和选择- 优化优惠券相关组件,导出所有优惠券相关组件
- 新增 ComponentContainer 组件,用于包裹和样式化 DIY 组件
pull/194/head
lrl 2025-08-04 09:09:39 +08:00
parent 38daaa2934
commit 2166ce3e4e
123 changed files with 11829 additions and 21 deletions

View File

@ -51,7 +51,9 @@
"highlight.js": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
"vue-dompurify-html": "catalog:",
"vue-router": "catalog:",
"vuedraggable": "catalog:"
},
"devDependencies": {
"unplugin-element-plus": "catalog:"

View File

@ -48,6 +48,8 @@ export namespace MallCombinationActivityApi {
combinationPrice?: number;
/** 商品列表 */
products: CombinationProduct[];
/** 图片 */
picUrl?: string;
}
/** 扩展 SKU 配置 */

View File

@ -52,7 +52,7 @@ export function deleteDiyPage(id: number) {
/** 获得装修页面属性 */
export function getDiyPageProperty(id: number) {
return requestClient.get<string>(`/promotion/diy-page/get-property?id=${id}`);
return requestClient.get(`/promotion/diy-page/get-property?id=${id}`);
}
/** 更新装修页面属性 */

View File

@ -55,6 +55,8 @@ export namespace MallSeckillActivityApi {
seckillPrice?: number;
/** 秒杀商品列表 */
products?: SeckillProduct[];
/** 图片 */
picUrl?: string;
}
/** 扩展 SKU 配置 */

View File

@ -0,0 +1,236 @@
<script lang="ts" setup>
import type { ButtonInstance, ScrollbarInstance } from 'element-plus';
import type { AppLink } from './data';
import { nextTick, ref } from 'vue';
import { getUrlNumberValue } from '@vben/utils';
import ProductCategorySelect from '#/views/mall/product/category/components/product-category-select.vue';
import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM } from './data';
// APP
defineOptions({ name: 'AppLinkSelectDialog' });
//
const emit = defineEmits<{
appLinkChange: [appLink: AppLink];
change: [link: string];
}>();
//
const activeGroup = ref(APP_LINK_GROUP_LIST[0]?.name);
// APP
const activeAppLink = ref({} as AppLink);
/** 打开弹窗 */
const dialogVisible = ref(false);
const open = (link: string) => {
activeAppLink.value.path = link;
dialogVisible.value = true;
//
const group = APP_LINK_GROUP_LIST.find((group) =>
group.links.some((linkItem) => {
const sameLink = isSameLink(linkItem.path, link);
if (sameLink) {
activeAppLink.value = { ...linkItem, path: link };
}
return sameLink;
}),
);
if (group) {
// 使 nextTick Dom
nextTick(() => handleGroupSelected(group.name));
}
};
defineExpose({ open });
// APP
const handleAppLinkSelected = (appLink: AppLink) => {
if (!isSameLink(appLink.path, activeAppLink.value.path)) {
activeAppLink.value = appLink;
}
switch (appLink.type) {
case APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST: {
detailSelectDialog.value.visible = true;
detailSelectDialog.value.type = appLink.type;
//
detailSelectDialog.value.id =
getUrlNumberValue(
'id',
`http://127.0.0.1${activeAppLink.value.path}`,
) || undefined;
break;
}
default: {
break;
}
}
};
const handleSubmit = () => {
dialogVisible.value = false;
emit('change', activeAppLink.value.path);
emit('appLinkChange', activeAppLink.value);
};
//
const groupTitleRefs = ref<HTMLInputElement[]>([]);
/**
* 处理右侧链接列表滚动
* @param {object} param0 滚动事件参数
* @param {number} param0.scrollTop 滚动条的位置
*/
const handleScroll = ({ scrollTop }: { scrollTop: number }) => {
const titleEl = groupTitleRefs.value.find((titleEl: HTMLInputElement) => {
//
const { offsetHeight, offsetTop } = titleEl;
//
return scrollTop >= offsetTop && scrollTop < offsetTop + offsetHeight;
});
//
if (titleEl && activeGroup.value !== titleEl.textContent) {
activeGroup.value = titleEl.textContent || '';
//
scrollToGroupBtn(activeGroup.value);
}
};
//
const linkScrollbar = ref<ScrollbarInstance>();
//
const handleGroupSelected = (group: string) => {
activeGroup.value = group;
const titleRef = groupTitleRefs.value.find(
(item: HTMLInputElement) => item.textContent === group,
);
if (titleRef) {
//
linkScrollbar.value?.setScrollTop(titleRef.offsetTop);
}
};
//
const groupScrollbar = ref<ScrollbarInstance>();
//
const groupBtnRefs = ref<ButtonInstance[]>([]);
//
const scrollToGroupBtn = (group: string) => {
const groupBtn = groupBtnRefs.value
.map((btn: ButtonInstance) => btn.ref)
.find((ref: HTMLButtonElement | undefined) => ref?.textContent === group);
if (groupBtn) {
groupScrollbar.value?.setScrollTop(groupBtn.offsetTop);
}
};
//
const isSameLink = (link1: string, link2: string) => {
return link1.split('?')[0] === link2.split('?')[0];
};
//
const detailSelectDialog = ref<{
id?: number;
type?: APP_LINK_TYPE_ENUM;
visible: boolean;
}>({
visible: false,
id: undefined,
type: undefined,
});
//
const handleProductCategorySelected = (id: number) => {
const url = new URL(activeAppLink.value.path, 'http://127.0.0.1');
// id
url.searchParams.set('id', `${id}`);
//
activeAppLink.value.path = `${url.pathname}${url.search}`;
//
detailSelectDialog.value.visible = false;
// id
detailSelectDialog.value.id = undefined;
};
</script>
<template>
<Dialog v-model="dialogVisible" title="选择链接" width="65%">
<div class="h-500px gap-8px flex">
<!-- 左侧分组列表 -->
<el-scrollbar
wrap-class="h-full"
ref="groupScrollbar"
view-class="flex flex-col"
>
<el-button
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
class="m-r-16px m-l-0px! justify-start! w-90px"
:class="[{ active: activeGroup === group.name }]"
ref="groupBtnRefs"
:text="activeGroup !== group.name"
:type="activeGroup === group.name ? 'primary' : 'default'"
@click="handleGroupSelected(group.name)"
>
{{ group.name }}
</el-button>
</el-scrollbar>
<!-- 右侧链接列表 -->
<el-scrollbar
class="h-full flex-1"
@scroll="handleScroll"
ref="linkScrollbar"
>
<div
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
>
<!-- 分组标题 -->
<div class="font-bold" ref="groupTitleRefs">{{ group.name }}</div>
<!-- 链接列表 -->
<el-tooltip
v-for="(appLink, appLinkIndex) in group.links"
:key="appLinkIndex"
:content="appLink.path"
placement="bottom"
:show-after="300"
>
<el-button
class="m-b-8px m-r-8px m-l-0px!"
:type="
isSameLink(appLink.path, activeAppLink.path)
? 'primary'
: 'default'
"
@click="handleAppLinkSelected(appLink)"
>
{{ appLink.name }}
</el-button>
</el-tooltip>
</div>
</el-scrollbar>
</div>
<!-- 底部对话框操作按钮 -->
<template #footer>
<el-button type="primary" @click="handleSubmit"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
<Dialog v-model="detailSelectDialog.visible" title="" width="50%">
<el-form class="min-h-200px">
<el-form-item
label="选择分类"
v-if="
detailSelectDialog.type === APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST
"
>
<ProductCategorySelect
v-model="detailSelectDialog.id"
:parent-id="0"
@update:model-value="handleProductCategorySelected"
/>
</el-form-item>
</el-form>
</Dialog>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,236 @@
// APP 链接分组
export interface AppLinkGroup {
// 分组名称
name: string;
// 链接列表
links: AppLink[];
}
// APP 链接
export interface AppLink {
// 链接名称
name: string;
// 链接地址
path: string;
// 链接的类型
type?: APP_LINK_TYPE_ENUM;
}
// APP 链接类型(需要特殊处理,例如商品详情)
export enum APP_LINK_TYPE_ENUM {
// 拼团活动
ACTIVITY_COMBINATION,
// 积分商城活动
ACTIVITY_POINT,
// 秒杀活动
ACTIVITY_SECKILL,
// 文章详情
ARTICLE_DETAIL,
// 优惠券详情
COUPON_DETAIL,
// 自定义页面详情
DIY_PAGE_DETAIL,
// 品类列表
PRODUCT_CATEGORY_LIST,
// 拼团商品详情
PRODUCT_DETAIL_COMBINATION,
// 商品详情
PRODUCT_DETAIL_NORMAL,
// 秒杀商品详情
PRODUCT_DETAIL_SECKILL,
// 商品列表
PRODUCT_LIST,
}
// APP 链接列表(做一下持久化?)
export const APP_LINK_GROUP_LIST = [
{
name: '商城',
links: [
{
name: '首页',
path: '/pages/index/index',
},
{
name: '商品分类',
path: '/pages/index/category',
type: APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST,
},
{
name: '购物车',
path: '/pages/index/cart',
},
{
name: '个人中心',
path: '/pages/index/user',
},
{
name: '商品搜索',
path: '/pages/index/search',
},
{
name: '自定义页面',
path: '/pages/index/page',
type: APP_LINK_TYPE_ENUM.DIY_PAGE_DETAIL,
},
{
name: '客服',
path: '/pages/chat/index',
},
{
name: '系统设置',
path: '/pages/public/setting',
},
{
name: '常见问题',
path: '/pages/public/faq',
},
],
},
{
name: '商品',
links: [
{
name: '商品列表',
path: '/pages/goods/list',
type: APP_LINK_TYPE_ENUM.PRODUCT_LIST,
},
{
name: '商品详情',
path: '/pages/goods/index',
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_NORMAL,
},
{
name: '拼团商品详情',
path: '/pages/goods/groupon',
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_COMBINATION,
},
{
name: '秒杀商品详情',
path: '/pages/goods/seckill',
type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_SECKILL,
},
],
},
{
name: '营销活动',
links: [
{
name: '拼团订单',
path: '/pages/activity/groupon/order',
},
{
name: '营销商品',
path: '/pages/activity/index',
},
{
name: '拼团活动',
path: '/pages/activity/groupon/list',
type: APP_LINK_TYPE_ENUM.ACTIVITY_COMBINATION,
},
{
name: '秒杀活动',
path: '/pages/activity/seckill/list',
type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL,
},
{
name: '积分商城活动',
path: '/pages/activity/point/list',
type: APP_LINK_TYPE_ENUM.ACTIVITY_POINT,
},
{
name: '签到中心',
path: '/pages/app/sign',
},
{
name: '优惠券中心',
path: '/pages/coupon/list',
},
{
name: '优惠券详情',
path: '/pages/coupon/detail',
type: APP_LINK_TYPE_ENUM.COUPON_DETAIL,
},
{
name: '文章详情',
path: '/pages/public/richtext',
type: APP_LINK_TYPE_ENUM.ARTICLE_DETAIL,
},
],
},
{
name: '分销商城',
links: [
{
name: '分销中心',
path: '/pages/commission/index',
},
{
name: '推广商品',
path: '/pages/commission/goods',
},
{
name: '分销订单',
path: '/pages/commission/order',
},
{
name: '我的团队',
path: '/pages/commission/team',
},
],
},
{
name: '支付',
links: [
{
name: '充值余额',
path: '/pages/pay/recharge',
},
{
name: '充值记录',
path: '/pages/pay/recharge-log',
},
],
},
{
name: '用户中心',
links: [
{
name: '用户信息',
path: '/pages/user/info',
},
{
name: '用户订单',
path: '/pages/order/list',
},
{
name: '售后订单',
path: '/pages/order/aftersale/list',
},
{
name: '商品收藏',
path: '/pages/user/goods-collect',
},
{
name: '浏览记录',
path: '/pages/user/goods-log',
},
{
name: '地址管理',
path: '/pages/user/address/list',
},
{
name: '用户佣金',
path: '/pages/user/wallet/commission',
},
{
name: '用户余额',
path: '/pages/user/wallet/money',
},
{
name: '用户积分',
path: '/pages/user/wallet/score',
},
],
},
] as AppLinkGroup[];

View File

@ -0,0 +1,43 @@
<script lang="ts" setup>
import { propTypes } from '@/utils/propTypes';
// APP
defineOptions({ name: 'AppLinkInput' });
//
const props = defineProps({
//
modelValue: propTypes.string.def(''),
});
// setter
const emit = defineEmits<{
'update:modelValue': [link: string];
}>();
//
const appLink = ref('');
//
const dialogRef = ref();
//
const handleOpenDialog = () => dialogRef.value?.open(appLink.value);
// APP
const handleLinkSelected = (link: string) => (appLink.value = link);
// getter
watch(
() => props.modelValue,
() => (appLink.value = props.modelValue),
{ immediate: true },
);
watch(
() => appLink.value,
() => emit('update:modelValue', appLink.value),
);
</script>
<template>
<el-input v-model="appLink" placeholder="输入或选择链接">
<template #append>
<el-button @click="handleOpenDialog"></el-button>
</template>
</el-input>
<AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" />
</template>

View File

@ -0,0 +1,255 @@
<script setup lang="ts">
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
import { computed } from 'vue';
/**
* 组件容器目前在中间部分
* 用于包裹组件为组件提供 背景外边距内边距边框等样式
*/
defineOptions({ name: 'ComponentContainer' });
const props = defineProps({
component: {
type: Object as () => DiyComponentWithStyle,
required: true,
},
active: {
type: Boolean,
default: false,
},
canMoveUp: {
type: Boolean,
default: false,
},
canMoveDown: {
type: Boolean,
default: false,
},
});
const emits = defineEmits<{
(e: 'move', direction: number): void;
(e: 'copy'): void;
(e: 'delete'): void;
}>();
type DiyComponentWithStyle = DiyComponent<any> & {
property: { style?: ComponentStyle };
};
/**
* 组件样式
*/
const style = computed(() => {
const componentStyle = props.component.property.style;
if (!componentStyle) {
return {};
}
return {
marginTop: `${componentStyle.marginTop || 0}px`,
marginBottom: `${componentStyle.marginBottom || 0}px`,
marginLeft: `${componentStyle.marginLeft || 0}px`,
marginRight: `${componentStyle.marginRight || 0}px`,
paddingTop: `${componentStyle.paddingTop || 0}px`,
paddingRight: `${componentStyle.paddingRight || 0}px`,
paddingBottom: `${componentStyle.paddingBottom || 0}px`,
paddingLeft: `${componentStyle.paddingLeft || 0}px`,
borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`,
borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`,
borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`,
borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`,
overflow: 'hidden',
background:
componentStyle.bgType === 'color'
? componentStyle.bgColor
: `url(${componentStyle.bgImg})`,
};
});
/**
* 移动组件
* @param direction 移动方向
*/
const handleMoveComponent = (direction: number) => {
emits('move', direction);
};
/**
* 复制组件
*/
const handleCopyComponent = () => {
emits('copy');
};
/**
* 删除组件
*/
const handleDeleteComponent = () => {
emits('delete');
};
</script>
<template>
<div class="component" :class="[{ active }]">
<div
:style="{
...style,
}"
>
<component :is="component.id" :property="component.property" />
</div>
<div class="component-wrap">
<!-- 左侧组件名悬浮的小贴条 -->
<div class="component-name" v-if="component.name">
{{ component.name }}
</div>
<!-- 右侧组件操作工具栏 -->
<div class="component-toolbar" v-if="component.name && active">
<VerticalButtonGroup type="primary">
<el-tooltip content="上移" placement="right">
<el-button
:disabled="!canMoveUp"
@click.stop="handleMoveComponent(-1)"
>
<Icon icon="ep:arrow-up" />
</el-button>
</el-tooltip>
<el-tooltip content="下移" placement="right">
<el-button
:disabled="!canMoveDown"
@click.stop="handleMoveComponent(1)"
>
<Icon icon="ep:arrow-down" />
</el-button>
</el-tooltip>
<el-tooltip content="复制" placement="right">
<el-button @click.stop="handleCopyComponent()">
<Icon icon="ep:copy-document" />
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="right">
<el-button @click.stop="handleDeleteComponent()">
<Icon icon="ep:delete" />
</el-button>
</el-tooltip>
</VerticalButtonGroup>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
$active-border-width: 2px;
$hover-border-width: 1px;
$name-position: -85px;
$toolbar-position: -55px;
/* 组件 */
.component {
position: relative;
cursor: move;
.component-wrap {
position: absolute;
top: 0;
left: -$active-border-width;
display: block;
width: 100%;
height: 100%;
/* 鼠标放到组件上时 */
&:hover {
border: $hover-border-width dashed var(--el-color-primary);
box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%);
.component-name {
top: $hover-border-width;
/* 防止加了边框之后,位置移动 */
left: $name-position - $hover-border-width;
}
}
/* 左侧:组件名称 */
.component-name {
position: absolute;
top: $active-border-width;
left: $name-position;
display: block;
width: 80px;
height: 25px;
font-size: 12px;
line-height: 25px;
color: #6a6a6a;
text-align: center;
background: #fff;
box-shadow:
0 0 4px #00000014,
0 2px 6px #0000000f,
0 4px 8px 2px #0000000a;
/* 右侧小三角 */
&::after {
position: absolute;
top: 7.5px;
right: -10px;
width: 0;
height: 0;
content: ' ';
border: 5px solid transparent;
border-left-color: #fff;
}
}
/* 右侧:组件操作工具栏 */
.component-toolbar {
position: absolute;
top: 0;
right: $toolbar-position;
display: none;
/* 左侧小三角 */
&::before {
position: absolute;
top: 10px;
left: -10px;
width: 0;
height: 0;
content: ' ';
border: 5px solid transparent;
border-right-color: #2d8cf0;
}
}
}
/* 组件选中时 */
&.active {
margin-bottom: 4px;
.component-wrap {
margin-bottom: $active-border-width + $active-border-width;
border: $active-border-width solid var(--el-color-primary) !important;
box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%);
.component-name {
top: 0 !important;
/* 防止加了边框之后,位置移动 */
left: $name-position - $active-border-width !important;
color: #fff;
background: var(--el-color-primary);
&::after {
border-left-color: var(--el-color-primary);
}
}
.component-toolbar {
display: block;
}
}
}
}
</style>

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import type { ComponentStyle } from '#/components/diy-editor/util';
import { useVModel } from '@vueuse/core';
/**
* 组件容器属性目前右边部分
* 用于包裹组件为组件提供 背景外边距内边距边框等样式
*/
defineOptions({ name: 'ComponentContainer' });
const props = defineProps<{ modelValue: ComponentStyle }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
const treeData = [
{
label: '外部边距',
prop: 'margin',
children: [
{
label: '上',
prop: 'marginTop',
},
{
label: '右',
prop: 'marginRight',
},
{
label: '下',
prop: 'marginBottom',
},
{
label: '左',
prop: 'marginLeft',
},
],
},
{
label: '内部边距',
prop: 'padding',
children: [
{
label: '上',
prop: 'paddingTop',
},
{
label: '右',
prop: 'paddingRight',
},
{
label: '下',
prop: 'paddingBottom',
},
{
label: '左',
prop: 'paddingLeft',
},
],
},
{
label: '边框圆角',
prop: 'borderRadius',
children: [
{
label: '上左',
prop: 'borderTopLeftRadius',
},
{
label: '上右',
prop: 'borderTopRightRadius',
},
{
label: '下右',
prop: 'borderBottomRightRadius',
},
{
label: '下左',
prop: 'borderBottomLeftRadius',
},
],
},
];
const handleSliderChange = (prop: string) => {
switch (prop) {
case 'borderRadius': {
formData.value.borderTopLeftRadius = formData.value.borderRadius;
formData.value.borderTopRightRadius = formData.value.borderRadius;
formData.value.borderBottomRightRadius = formData.value.borderRadius;
formData.value.borderBottomLeftRadius = formData.value.borderRadius;
break;
}
case 'margin': {
formData.value.marginTop = formData.value.margin;
formData.value.marginRight = formData.value.margin;
formData.value.marginBottom = formData.value.margin;
formData.value.marginLeft = formData.value.margin;
break;
}
case 'padding': {
formData.value.paddingTop = formData.value.padding;
formData.value.paddingRight = formData.value.padding;
formData.value.paddingBottom = formData.value.padding;
formData.value.paddingLeft = formData.value.padding;
break;
}
}
};
</script>
<template>
<el-tabs stretch>
<!-- 每个组件的自定义内容 -->
<el-tab-pane label="内容" v-if="$slots.default">
<slot></slot>
</el-tab-pane>
<!-- 每个组件的通用内容 -->
<el-tab-pane label="样式" lazy>
<el-card header="组件样式" class="property-group">
<el-form :model="formData" label-width="80px">
<el-form-item label="组件背景" prop="bgType">
<el-radio-group v-model="formData.bgType">
<el-radio value="color">纯色</el-radio>
<el-radio value="img">图片</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="选择颜色"
prop="bgColor"
v-if="formData.bgType === 'color'"
>
<ColorInput v-model="formData.bgColor" />
</el-form-item>
<el-form-item label="上传图片" prop="bgImg" v-else>
<UploadImg v-model="formData.bgImg" :limit="1">
<template #tip>建议宽度 750px</template>
</UploadImg>
</el-form-item>
<el-tree
:data="treeData"
:expand-on-click-node="false"
default-expand-all
>
<template #default="{ node, data }">
<el-form-item
:label="data.label"
:prop="data.prop"
:label-width="node.level === 1 ? '80px' : '62px'"
class="m-b-0! w-full"
>
<el-slider
v-model="formData[data.prop as keyof ComponentStyle]"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
@input="handleSliderChange(data.prop)"
/>
</el-form-item>
</template>
</el-tree>
<slot name="style" :style="formData"></slot>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</template>
<style scoped lang="scss">
:deep(.el-slider__runway) {
margin-right: 16px;
}
:deep(.el-input-number) {
width: 50px;
}
</style>

View File

@ -0,0 +1,219 @@
<script setup lang="ts">
import type {
DiyComponent,
DiyComponentLibrary,
} from '#/components/diy-editor/util';
import { reactive, watch } from 'vue';
import { cloneDeep } from '@vben/utils';
import draggable from 'vuedraggable';
import { componentConfigs } from '../components/mobile/index';
/** 组件库:目前左侧的【基础组件】、【图文组件】部分 */
defineOptions({ name: 'ComponentLibrary' });
//
const props = defineProps<{
list: DiyComponentLibrary[];
}>();
//
const groups = reactive<any[]>([]);
//
const extendGroups = reactive<string[]>([]);
// list DiyComponentLibrary name
watch(
() => props.list,
() => {
//
extendGroups.length = 0;
groups.length = 0;
//
props.list.forEach((group) => {
//
if (group.extended) {
extendGroups.push(group.name);
}
//
const components = group.components
.map((name) => componentConfigs[name] as DiyComponent<any>)
.filter(Boolean);
if (components.length > 0) {
groups.push({
name: group.name,
components,
});
}
});
},
{
immediate: true,
},
);
//
const handleCloneComponent = (component: DiyComponent<any>) => {
const instance = cloneDeep(component);
instance.uid = Date.now();
return instance;
};
</script>
<template>
<el-aside class="editor-left" width="261px">
<el-scrollbar>
<el-collapse v-model="extendGroups">
<el-collapse-item
v-for="group in groups"
:key="group.name"
:name="group.name"
:title="group.name"
>
<draggable
class="component-container"
ghost-class="draggable-ghost"
item-key="index"
:list="group.components"
:sort="false"
:group="{ name: 'component', pull: 'clone', put: false }"
:clone="handleCloneComponent"
:animation="200"
:force-fallback="true"
>
<template #item="{ element }">
<div>
<div class="drag-placement">组件放置区域</div>
<div class="component">
<Icon :icon="element.icon" :size="32" />
<span class="mt-4px text-12px">{{ element.name }}</span>
</div>
</div>
</template>
</draggable>
</el-collapse-item>
</el-collapse>
</el-scrollbar>
</el-aside>
</template>
<style scoped lang="scss">
.editor-left {
z-index: 1;
flex-shrink: 0;
user-select: none;
box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
:deep(.el-collapse) {
border-top: none;
}
:deep(.el-collapse-item__wrap) {
border-bottom: none;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
:deep(.el-collapse-item__header) {
height: 32px;
padding: 0 24px;
line-height: 32px;
background-color: var(--el-bg-color-page);
border-bottom: none;
}
.component-container {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.component {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 86px;
height: 86px;
cursor: move;
border-right: 1px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
.el-icon {
margin-bottom: 4px;
color: gray;
}
}
.component.active,
.component:hover {
color: var(--el-color-white);
background: var(--el-color-primary);
.el-icon {
color: var(--el-color-white);
}
}
.component:nth-of-type(3n) {
border-right: none;
}
}
/* 拖拽占位提示,默认不显示 */
.drag-placement {
display: none;
color: #fff;
}
.drag-area {
/* 拖拽到手机区域时的样式 */
.draggable-ghost {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 40px;
/* 条纹背景 */
background: linear-gradient(
45deg,
#91a8d5 0,
#91a8d5 10%,
#94b4eb 10%,
#94b4eb 50%,
#91a8d5 50%,
#91a8d5 60%,
#94b4eb 60%,
#94b4eb
);
background-size: 1rem 1rem;
transition: all 0.5s;
span {
display: inline-block;
width: 140px;
height: 25px;
font-size: 12px;
line-height: 25px;
color: #fff;
text-align: center;
background: #5487df;
}
/* 拖拽时隐藏组件 */
.component {
display: none;
}
/* 拖拽时显示占位提示 */
.drag-placement {
display: block;
}
}
}
</style>

View File

@ -0,0 +1,61 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 轮播图属性 */
export interface CarouselProperty {
// 类型:默认 | 卡片
type: 'card' | 'default';
// 指示器样式:点 | 数字
indicator: 'dot' | 'number';
// 是否自动播放
autoplay: boolean;
// 播放间隔
interval: number;
// 轮播内容
items: CarouselItemProperty[];
// 组件样式
style: ComponentStyle;
}
// 轮播内容属性
export interface CarouselItemProperty {
// 类型:图片 | 视频
type: 'img' | 'video';
// 图片链接
imgUrl: string;
// 视频链接
videoUrl: string;
// 跳转链接
url: string;
}
// 定义组件
export const component = {
id: 'Carousel',
name: '轮播图',
icon: 'system-uicons:carousel',
property: {
type: 'default',
indicator: 'dot',
autoplay: false,
interval: 3,
items: [
{
type: 'img',
imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg',
videoUrl: '',
},
{
type: 'img',
imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg',
videoUrl: '',
},
] as CarouselItemProperty[],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<CarouselProperty>;

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { CarouselProperty } from './config';
import { ref } from 'vue';
/** 轮播图 */
defineOptions({ name: 'Carousel' });
defineProps<{ property: CarouselProperty }>();
const currentIndex = ref(0);
const handleIndexChange = (index: number) => {
currentIndex.value = index + 1;
};
</script>
<template>
<!-- 无图片 -->
<div
class="h-250px bg-gray-3 flex items-center justify-center"
v-if="property.items.length === 0"
>
<Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
</div>
<div v-else class="relative">
<el-carousel
height="174px"
:type="property.type === 'card' ? 'card' : ''"
:autoplay="property.autoplay"
:interval="property.interval * 1000"
:indicator-position="property.indicator === 'number' ? 'none' : undefined"
@change="handleIndexChange"
>
<el-carousel-item v-for="(item, index) in property.items" :key="index">
<el-image class="h-full w-full" :src="item.imgUrl" />
</el-carousel-item>
</el-carousel>
<div
v-if="property.indicator === 'number'"
class="bottom-10px right-10px p-x-8px p-y-2px text-10px absolute rounded-xl bg-black text-white opacity-40"
>
{{ currentIndex }} / {{ property.items.length }}
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import type { CarouselProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'CarouselProperty' });
const props = defineProps<{ modelValue: CarouselProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="样式设置" class="property-group" shadow="never">
<el-form-item label="样式" prop="type">
<el-radio-group v-model="formData.type">
<el-tooltip class="item" content="默认" placement="bottom">
<el-radio-button value="default">
<Icon icon="system-uicons:carousel" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="卡片" placement="bottom">
<el-radio-button value="card">
<Icon icon="ic:round-view-carousel" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="指示器" prop="indicator">
<el-radio-group v-model="formData.indicator">
<el-radio value="dot">小圆点</el-radio>
<el-radio value="number">数字</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否轮播" prop="autoplay">
<el-switch v-model="formData.autoplay" />
</el-form-item>
<el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay">
<el-slider
v-model="formData.interval"
:max="10"
:min="0.5"
:step="0.5"
show-input
input-size="small"
:show-input-controls="false"
/>
<el-text type="info">单位</el-text>
</el-form-item>
</el-card>
<el-card header="内容设置" class="property-group" shadow="never">
<Draggable v-model="formData.items" :empty-item="{ type: 'img' }">
<template #default="{ element }">
<el-form-item
label="类型"
prop="type"
class="m-b-8px!"
label-width="40px"
>
<el-radio-group v-model="element.type">
<el-radio value="img">图片</el-radio>
<el-radio value="video">视频</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="图片"
class="m-b-8px!"
label-width="40px"
v-if="element.type === 'img'"
>
<UploadImg
v-model="element.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
/>
</el-form-item>
<template v-else>
<el-form-item label="封面" class="m-b-8px!" label-width="40px">
<UploadImg
v-model="element.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
/>
</el-form-item>
<el-form-item label="视频" class="m-b-8px!" label-width="40px">
<UploadFile
v-model="element.videoUrl"
:file-type="['mp4']"
:limit="1"
:file-size="100"
class="min-w-80px"
/>
</el-form-item>
</template>
<el-form-item label="链接" class="m-b-8px!" label-width="40px">
<AppLinkInput v-model="element.url" />
</el-form-item>
</template>
</Draggable>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,35 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { floatToFixed2 } from '@vben/utils';
import { PromotionDiscountTypeEnum } from '#/utils/constants';
// 优惠值
export const CouponDiscount = defineComponent({
name: 'CouponDiscount',
props: {
coupon: {
type: Object as () => MallCouponTemplateApi.CouponTemplate,
required: true,
},
},
setup(props) {
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
// 折扣
let value = `${coupon.discountPercent / 10}`;
let suffix = ' 折';
// 满减
if (coupon.discountType === PromotionDiscountTypeEnum.PRICE.type) {
value = floatToFixed2(coupon.discountPrice);
suffix = ' 元';
}
return () => (
<div>
<span class={'text-20px font-bold'}>{value}</span>
<span>{suffix}</span>
</div>
);
},
});

View File

@ -0,0 +1,35 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { floatToFixed2 } from '@vben/utils';
import { PromotionDiscountTypeEnum } from '#/utils/constants';
// 优惠描述
export const CouponDiscountDesc = defineComponent({
name: 'CouponDiscountDesc',
props: {
coupon: {
type: Object as () => MallCouponTemplateApi.CouponTemplate,
required: true,
},
},
setup(props) {
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
// 使用条件
const useCondition =
coupon.usePrice > 0 ? `${floatToFixed2(coupon.usePrice)}元,` : '';
// 优惠描述
const discountDesc =
coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
? `${floatToFixed2(coupon.discountPrice)}`
: `${coupon.discountPercent / 10}`;
return () => (
<div>
<span>{useCondition}</span>
<span>{discountDesc}</span>
</div>
);
},
});

View File

@ -0,0 +1,29 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { formatDate } from '@vben/utils';
import { CouponTemplateValidityTypeEnum } from '#/utils/constants';
// 有效期
export const CouponValidTerm = defineComponent({
name: 'CouponValidTerm',
props: {
coupon: {
type: Object as () => MallCouponTemplateApi.CouponTemplate,
required: true,
},
},
setup(props) {
const coupon = props.coupon as MallCouponTemplateApi.CouponTemplate;
const text =
coupon.validityType === CouponTemplateValidityTypeEnum.DATE.type
? `有效期:${formatDate(coupon.validStartTime, 'YYYY-MM-DD')}${formatDate(
coupon.validEndTime,
'YYYY-MM-DD',
)}`
: `领取后第 ${coupon.fixedStartTerm} - ${coupon.fixedEndTerm} 天内可用`;
return () => <div>{text}</div>;
},
});

View File

@ -0,0 +1,4 @@
// 导出所有优惠券相关组件
export { CouponDiscount } from './CouponDiscount';
export { CouponDiscountDesc } from './CouponDiscountDesc';
export { CouponValidTerm } from './CouponValidTerm';

View File

@ -0,0 +1,50 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 商品卡片属性 */
export interface CouponCardProperty {
// 列数
columns: number;
// 背景图
bgImg: string;
// 文字颜色
textColor: string;
// 按钮样式
button: {
// 背景颜色
bgColor: string;
// 颜色
color: string;
};
// 间距
space: number;
// 优惠券编号列表
couponIds: number[];
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'CouponCard',
name: '优惠券',
icon: 'ep:ticket',
property: {
columns: 1,
bgImg: '',
textColor: '#E9B461',
button: {
color: '#434343',
bgColor: '',
},
space: 0,
couponIds: [],
style: {
bgType: 'color',
bgColor: '',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<CouponCardProperty>;

View File

@ -0,0 +1,163 @@
<script setup lang="ts">
import type { CouponCardProperty } from './config';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { onMounted, ref, watch } from 'vue';
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
import {
CouponDiscount,
CouponDiscountDesc,
CouponValidTerm,
} from './component';
/** 商品卡片 */
defineOptions({ name: 'CouponCard' });
//
const props = defineProps<{ property: CouponCardProperty }>();
//
const couponList = ref<MallCouponTemplateApi.CouponTemplate[]>([]);
watch(
() => props.property.couponIds,
async () => {
if (props.property.couponIds?.length > 0) {
couponList.value = await CouponTemplateApi.getCouponTemplateList(
props.property.couponIds,
);
}
},
{
immediate: true,
deep: true,
},
);
//
const phoneWidth = ref(375);
//
const containerRef = ref();
//
const scrollbarWidth = ref('100%');
//
const couponWidth = ref(375);
//
watch(
() => [props.property, phoneWidth, couponList.value.length],
() => {
// - * ( - 1)/
couponWidth.value =
(phoneWidth.value - props.property.space * (props.property.columns - 1)) /
props.property.columns;
//
scrollbarWidth.value = `${
couponWidth.value * couponList.value.length +
props.property.space * (couponList.value.length - 1)
}px`;
},
{ immediate: true, deep: true },
);
onMounted(() => {
//
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375;
});
</script>
<template>
<el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef">
<div
class="text-12px flex flex-row"
:style="{
gap: `${property.space}px`,
width: scrollbarWidth,
}"
>
<div
class="box-content"
:style="{
background: property.bgImg
? `url(${property.bgImg}) 100% center / 100% 100% no-repeat`
: '#fff',
width: `${couponWidth}px`,
color: property.textColor,
}"
v-for="(coupon, index) in couponList"
:key="index"
>
<!-- 布局11-->
<div
v-if="property.columns === 1"
class="m-l-16px p-8px flex flex-row justify-between"
>
<div class="gap-4px flex flex-col justify-evenly">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<!-- 有效期 -->
<CouponValidTerm :coupon="coupon" />
</div>
<div class="flex flex-col justify-evenly">
<div
class="rounded-20px p-x-8px p-y-2px"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
<!-- 布局22-->
<div
v-else-if="property.columns === 2"
class="m-l-16px p-8px flex flex-row justify-between"
>
<div class="gap-4px flex flex-col justify-evenly">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<!-- 领取说明 -->
<div v-if="coupon.totalCount >= 0">
仅剩{{ coupon.totalCount - coupon.takeCount }}
</div>
<div v-else-if="coupon.totalCount === -1">仅剩不限制</div>
</div>
<div class="flex flex-col">
<div
class="w-20px rounded-20px p-x-2px p-y-8px h-full text-center"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
<!-- 布局33-->
<div
v-else
class="gap-4px p-4px flex flex-col items-center justify-around"
>
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<div
class="rounded-20px p-x-8px p-y-2px"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
</div>
</el-scrollbar>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import type { CouponCardProperty } from './config';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { ref, watch } from 'vue';
import { floatToFixed2 } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate';
import {
CouponTemplateTakeTypeEnum,
PromotionDiscountTypeEnum,
} from '#/utils/constants';
import CouponSelect from '#/views/mall/promotion/coupon/components/coupon-select.vue';
//
defineOptions({ name: 'CouponCardProperty' });
const props = defineProps<{ modelValue: CouponCardProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const couponList = ref<MallCouponTemplateApi.CouponTemplate[]>([]);
const couponSelectDialog = ref();
//
const handleAddCoupon = () => {
couponSelectDialog.value.open();
};
const handleCouponSelect = () => {
formData.value.couponIds = couponList.value.map((coupon) => coupon.id);
};
watch(
() => formData.value.couponIds,
async () => {
if (formData.value.couponIds?.length > 0) {
couponList.value = await CouponTemplateApi.getCouponTemplateList(
formData.value.couponIds,
);
}
},
{
immediate: true,
deep: true,
},
);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="优惠券列表" class="property-group" shadow="never">
<div
v-for="(coupon, index) in couponList"
:key="index"
class="flex items-center justify-between"
>
<el-text size="large" truncated>{{ coupon.name }}</el-text>
<el-text type="info" truncated>
<span v-if="coupon.usePrice > 0">
{{ floatToFixed2(coupon.usePrice) }}
</span>
<span
v-if="
coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
"
>
{{ floatToFixed2(coupon.discountPrice) }}
</span>
<span v-else> {{ coupon.discountPercent }} </span>
</el-text>
</div>
<el-form-item label-width="0">
<el-button
@click="handleAddCoupon"
type="primary"
plain
class="m-t-8px w-full"
>
<Icon icon="ep:plus" class="mr-5px" /> 添加
</el-button>
</el-form-item>
</el-card>
<el-card header="优惠券样式" class="property-group" shadow="never">
<el-form-item label="列数" prop="type">
<el-radio-group v-model="formData.columns">
<el-tooltip class="item" content="一列" placement="bottom">
<el-radio-button :value="1">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="二列" placement="bottom">
<el-radio-button :value="2">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button :value="3">
<Icon icon="fluent:text-column-three-24-filled" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="背景图片" prop="bgImg">
<UploadImg
v-model="formData.bgImg"
height="80px"
width="100%"
class="min-w-160px"
/>
</el-form-item>
<el-form-item label="文字颜色" prop="textColor">
<ColorInput v-model="formData.textColor" />
</el-form-item>
<el-form-item label="按钮背景" prop="button.bgColor">
<ColorInput v-model="formData.button.bgColor" />
</el-form-item>
<el-form-item label="按钮文字" prop="button.color">
<ColorInput v-model="formData.button.color" />
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
<!-- 优惠券选择 -->
<CouponSelect
ref="couponSelectDialog"
v-model:multiple-selection="couponList"
:take-type="CouponTemplateTakeTypeEnum.USER.type"
@change="handleCouponSelect"
/>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,29 @@
import type { DiyComponent } from '#/components/diy-editor/util';
/** 分割线属性 */
export interface DividerProperty {
// 高度
height: number;
// 线宽
lineWidth: number;
// 边距类型
paddingType: 'horizontal' | 'none';
// 颜色
lineColor: string;
// 类型
borderType: 'dashed' | 'dotted' | 'none' | 'solid';
}
// 定义组件
export const component = {
id: 'Divider',
name: '分割线',
icon: 'tdesign:component-divider-vertical',
property: {
height: 30,
lineWidth: 1,
paddingType: 'none',
lineColor: '#dcdfe6',
borderType: 'solid',
},
} as DiyComponent<DividerProperty>;

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { DividerProperty } from './config';
/** 页面顶部导航栏 */
defineOptions({ name: 'Divider' });
defineProps<{ property: DividerProperty }>();
</script>
<template>
<div
class="flex items-center"
:style="{
height: `${property.height}px`,
}"
>
<div
class="w-full"
:style="{
borderTopStyle: property.borderType,
borderTopColor: property.lineColor,
borderTopWidth: `${property.lineWidth}px`,
margin: property.paddingType === 'none' ? '0' : '0px 16px',
}"
></div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import type { DividerProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'DividerProperty' });
const props = defineProps<{ modelValue: DividerProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
// 线
const BORDER_TYPES = [
{
icon: 'vaadin:line-h',
text: '实线',
type: 'solid',
},
{
icon: 'tabler:line-dashed',
text: '虚线',
type: 'dashed',
},
{
icon: 'tabler:line-dotted',
text: '点线',
type: 'dotted',
},
{
icon: 'entypo:progress-empty',
text: '无',
type: 'none',
},
];
</script>
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="高度" prop="height">
<el-slider
v-model="formData.height"
:min="1"
:max="100"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="选择样式" prop="borderType">
<el-radio-group v-model="formData!.borderType">
<el-tooltip
placement="top"
v-for="(item, index) in BORDER_TYPES"
:key="index"
:content="item.text"
>
<el-radio-button :value="item.type">
<Icon :icon="item.icon" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<template v-if="formData.borderType !== 'none'">
<el-form-item label="线宽" prop="lineWidth">
<el-slider
v-model="formData.lineWidth"
:min="1"
:max="30"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="左右边距" prop="paddingType">
<el-radio-group v-model="formData!.paddingType">
<el-tooltip content="无边距" placement="top">
<el-radio-button value="none">
<Icon icon="tabler:box-padding" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="左右留边" placement="top">
<el-radio-button value="horizontal">
<Icon icon="vaadin:padding" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="颜色">
<!-- 分割线颜色 -->
<ColorInput v-model="formData.lineColor" />
</el-form-item>
</template>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,36 @@
import type { DiyComponent } from '#/components/diy-editor/util';
// 悬浮按钮属性
export interface FloatingActionButtonProperty {
// 展开方向
direction: 'horizontal' | 'vertical';
// 是否显示文字
showText: boolean;
// 按钮列表
list: FloatingActionButtonItemProperty[];
}
// 悬浮按钮项属性
export interface FloatingActionButtonItemProperty {
// 图片地址
imgUrl: string;
// 跳转连接
url: string;
// 文字
text: string;
// 文字颜色
textColor: string;
}
// 定义组件
export const component = {
id: 'FloatingActionButton',
name: '悬浮按钮',
icon: 'tabler:float-right',
position: 'fixed',
property: {
direction: 'vertical',
showText: true,
list: [{ textColor: '#fff' }],
},
} as DiyComponent<FloatingActionButtonProperty>;

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import type { FloatingActionButtonProperty } from './config';
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
/** 悬浮按钮 */
defineOptions({ name: 'FloatingActionButton' });
//
defineProps<{ property: FloatingActionButtonProperty }>();
//
const expanded = ref(false);
// /
const handleToggleFab = () => {
expanded.value = !expanded.value;
};
const handleActive = (index: number) => {
ElMessage.success(`点击了${index}`);
};
</script>
<template>
<div
class="bottom-32px z-12 gap-12px absolute right-[calc(50%-375px/2+32px)] flex items-center"
:class="[
{
'flex-row': property.direction === 'horizontal',
'flex-col': property.direction === 'vertical',
},
]"
>
<template v-if="expanded">
<div
v-for="(item, index) in property.list"
:key="index"
class="flex flex-col items-center"
@click="handleActive(index)"
>
<el-image :src="item.imgUrl" fit="contain" class="h-27px w-27px">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<Icon icon="ep:picture" :color="item.textColor" />
</div>
</template>
</el-image>
<span
v-if="property.showText"
class="mt-4px text-12px"
:style="{ color: item.textColor }"
>
{{ item.text }}
</span>
</div>
</template>
<!-- todo: @owen 使用APP主题色 -->
<el-button type="primary" size="large" circle @click="handleToggleFab">
<Icon icon="ep:plus" class="fab-icon" :class="[{ active: expanded }]" />
</el-button>
</div>
<!-- 模态背景展开时显示点击后折叠 -->
<div v-if="expanded" class="modal-bg" @click="handleToggleFab"></div>
</template>
<style scoped lang="scss">
/* 模态背景 */
.modal-bg {
position: absolute;
top: 0;
left: calc(50% - 375px / 2);
z-index: 11;
width: 375px;
height: 100%;
background-color: rgb(0 0 0 / 40%);
}
.fab-icon {
transform: rotate(0deg);
transition: transform 0.3s;
&.active {
transform: rotate(135deg);
}
}
</style>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import type { FloatingActionButtonProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'FloatingActionButtonProperty' });
const props = defineProps<{ modelValue: FloatingActionButtonProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<el-form label-width="80px" :model="formData">
<el-card header="按钮配置" class="property-group" shadow="never">
<el-form-item label="展开方向" prop="direction">
<el-radio-group v-model="formData.direction">
<el-radio value="vertical">垂直</el-radio>
<el-radio value="horizontal">水平</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="显示文字" prop="showText">
<el-switch v-model="formData.showText" />
</el-form-item>
</el-card>
<el-card header="按钮列表" class="property-group" shadow="never">
<Draggable v-model="formData.list" :empty-item="{ textColor: '#fff' }">
<template #default="{ element, index }">
<el-form-item label="图标" :prop="`list[${index}].imgUrl`">
<UploadImg v-model="element.imgUrl" height="56px" width="56px" />
</el-form-item>
<el-form-item label="文字" :prop="`list[${index}].text`">
<InputWithColor
v-model="element.text"
v-model:color="element.textColor"
/>
</el-form-item>
<el-form-item label="跳转链接" :prop="`list[${index}].url`">
<AppLinkInput v-model="element.url" />
</el-form-item>
</template>
</Draggable>
</el-card>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,175 @@
import type { StyleValue } from 'vue';
import type { HotZoneItemProperty } from '#/components/diy-editor/components/mobile/HotZone/config';
// 热区的最小宽高
export const HOT_ZONE_MIN_SIZE = 100;
// 控制的类型
export enum CONTROL_TYPE_ENUM {
LEFT,
TOP,
WIDTH,
HEIGHT,
}
// 定义热区的控制点
export interface ControlDot {
position: string;
types: CONTROL_TYPE_ENUM[];
style: StyleValue;
}
// 热区的8个控制点
export const CONTROL_DOT_LIST = [
{
position: '左上角',
types: [
CONTROL_TYPE_ENUM.LEFT,
CONTROL_TYPE_ENUM.TOP,
CONTROL_TYPE_ENUM.WIDTH,
CONTROL_TYPE_ENUM.HEIGHT,
],
style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' },
},
{
position: '上方中间',
types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT],
style: {
left: '50%',
top: '-5px',
cursor: 'n-resize',
transform: 'translateX(-50%)',
},
},
{
position: '右上角',
types: [
CONTROL_TYPE_ENUM.TOP,
CONTROL_TYPE_ENUM.WIDTH,
CONTROL_TYPE_ENUM.HEIGHT,
],
style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' },
},
{
position: '右侧中间',
types: [CONTROL_TYPE_ENUM.WIDTH],
style: {
right: '-5px',
top: '50%',
cursor: 'e-resize',
transform: 'translateX(-50%)',
},
},
{
position: '右下角',
types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' },
},
{
position: '下方中间',
types: [CONTROL_TYPE_ENUM.HEIGHT],
style: {
left: '50%',
bottom: '-5px',
cursor: 's-resize',
transform: 'translateX(-50%)',
},
},
{
position: '左下角',
types: [
CONTROL_TYPE_ENUM.LEFT,
CONTROL_TYPE_ENUM.WIDTH,
CONTROL_TYPE_ENUM.HEIGHT,
],
style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' },
},
{
position: '左侧中间',
types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH],
style: {
left: '-5px',
top: '50%',
cursor: 'w-resize',
transform: 'translateX(-50%)',
},
},
] as ControlDot[];
// region 热区的缩放
// 热区的缩放比例
export const HOT_ZONE_SCALE_RATE = 2;
// 缩小:缩回适合手机屏幕的大小
export const zoomOut = (list?: HotZoneItemProperty[]) => {
return (
list?.map((hotZone) => ({
...hotZone,
left: (hotZone.left /= HOT_ZONE_SCALE_RATE),
top: (hotZone.top /= HOT_ZONE_SCALE_RATE),
width: (hotZone.width /= HOT_ZONE_SCALE_RATE),
height: (hotZone.height /= HOT_ZONE_SCALE_RATE),
})) || []
);
};
// 放大:作用是为了方便在电脑屏幕上编辑
export const zoomIn = (list?: HotZoneItemProperty[]) => {
return (
list?.map((hotZone) => ({
...hotZone,
left: (hotZone.left *= HOT_ZONE_SCALE_RATE),
top: (hotZone.top *= HOT_ZONE_SCALE_RATE),
width: (hotZone.width *= HOT_ZONE_SCALE_RATE),
height: (hotZone.height *= HOT_ZONE_SCALE_RATE),
})) || []
);
};
// endregion
/**
*
*
* 使vueuseuseDraggable使
* @param hotZone
* @param downEvent
* @param callback
*/
export const useDraggable = (
hotZone: HotZoneItemProperty,
downEvent: MouseEvent,
callback: (
left: number,
top: number,
width: number,
height: number,
moveWidth: number,
moveHeight: number,
) => void,
) => {
// 阻止事件冒泡
downEvent.stopPropagation();
// 移动前的鼠标坐标
const { clientX: startX, clientY: startY } = downEvent;
// 移动前的热区坐标、大小
const { left, top, width, height } = hotZone;
// 监听鼠标移动
const handleMouseMove = (e: MouseEvent) => {
// 移动宽度
const moveWidth = e.clientX - startX;
// 移动高度
const moveHeight = e.clientY - startY;
// 移动回调
callback(left, top, width, height, moveWidth, moveHeight);
};
// 松开鼠标后,结束拖拽
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};

View File

@ -0,0 +1,280 @@
<script setup lang="ts">
import type { ControlDot } from './controller';
import type { AppLink } from '#/components/app-link-input/data';
import type { HotZoneItemProperty } from '#/components/diy-editor/components/mobile/HotZone/config';
import { ref } from 'vue';
import {
CONTROL_DOT_LIST,
CONTROL_TYPE_ENUM,
HOT_ZONE_MIN_SIZE,
useDraggable,
zoomIn,
zoomOut,
} from './controller';
/** 热区编辑对话框 */
defineOptions({ name: 'HotZoneEditDialog' });
//
const props = defineProps({
modelValue: {
type: Array<HotZoneItemProperty>,
default: () => [],
},
imgUrl: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const formData = ref<HotZoneItemProperty[]>([]);
//
const dialogVisible = ref(false);
//
const open = () => {
//
formData.value = zoomIn(props.modelValue);
dialogVisible.value = true;
};
// open
defineExpose({ open });
//
const container = ref<HTMLDivElement>();
//
const handleAdd = () => {
formData.value.push({
width: HOT_ZONE_MIN_SIZE,
height: HOT_ZONE_MIN_SIZE,
top: 0,
left: 0,
} as HotZoneItemProperty);
};
//
const handleRemove = (hotZone: HotZoneItemProperty) => {
formData.value = formData.value.filter((item) => item !== hotZone);
};
//
const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => {
useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => {
setLeft(item, left + moveWidth);
setTop(item, top + moveHeight);
});
};
//
const handleResize = (
item: HotZoneItemProperty,
ctrlDot: ControlDot,
e: MouseEvent,
) => {
useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => {
ctrlDot.types.forEach((type) => {
switch (type) {
case CONTROL_TYPE_ENUM.HEIGHT: {
{
//
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP)
? -1
: 1;
setHeight(item, height + moveHeight * direction);
}
break;
}
case CONTROL_TYPE_ENUM.LEFT: {
setLeft(item, left + moveWidth);
break;
}
case CONTROL_TYPE_ENUM.TOP: {
setTop(item, top + moveHeight);
break;
}
case CONTROL_TYPE_ENUM.WIDTH: {
{
//
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT)
? -1
: 1;
setWidth(item, width + moveWidth * direction);
}
break;
}
}
});
});
};
// X
const setLeft = (item: HotZoneItemProperty, left: number) => {
//
if (left >= 0 && left <= container.value!.offsetWidth - item.width) {
item.left = left;
}
};
// Y
const setTop = (item: HotZoneItemProperty, top: number) => {
//
if (top >= 0 && top <= container.value!.offsetHeight - item.height) {
item.top = top;
}
};
//
const setWidth = (item: HotZoneItemProperty, width: number) => {
// &&
if (
width >= HOT_ZONE_MIN_SIZE &&
item.left + width <= container.value!.offsetWidth
) {
item.width = width;
}
};
//
const setHeight = (item: HotZoneItemProperty, height: number) => {
// &&
if (
height >= HOT_ZONE_MIN_SIZE &&
item.top + height <= container.value!.offsetHeight
) {
item.height = height;
}
};
//
const handleSubmit = () => {
// handleClose
dialogVisible.value = false;
};
//
const handleClose = () => {
//
const list = zoomOut(formData.value);
emit('update:modelValue', list);
};
const activeHotZone = ref<HotZoneItemProperty>();
const appLinkDialogRef = ref();
const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => {
activeHotZone.value = hotZone;
appLinkDialogRef.value.open(hotZone.url);
};
const handleAppLinkChange = (appLink: AppLink) => {
if (!appLink || !activeHotZone.value) return;
activeHotZone.value.name = appLink.name;
activeHotZone.value.url = appLink.path;
};
</script>
<template>
<Dialog
v-model="dialogVisible"
title="设置热区"
width="780"
@close="handleClose"
>
<div ref="container" class="w-750px relative h-full">
<el-image
:src="imgUrl"
class="w-750px pointer-events-none h-full select-none"
/>
<div
v-for="(item, hotZoneIndex) in formData"
:key="hotZoneIndex"
class="hot-zone"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`,
}"
@mousedown="handleMove(item, $event)"
@dblclick="handleShowAppLinkDialog(item)"
>
<span class="pointer-events-none select-none">{{
item.name || '双击选择链接'
}}</span>
<Icon
icon="ep:close"
class="delete"
:size="14"
@click="handleRemove(item)"
/>
<!-- 8个控制点 -->
<span
class="ctrl-dot"
v-for="(dot, dotIndex) in CONTROL_DOT_LIST"
:key="dotIndex"
:style="dot.style"
@mousedown="handleResize(item, dot, $event)"
></span>
</div>
</div>
<template #footer>
<el-button @click="handleAdd" type="primary" plain>
<Icon icon="ep:plus" class="mr-5px" />
添加热区
</el-button>
<el-button @click="handleSubmit" type="primary" plain>
<Icon icon="ep:check" class="mr-5px" />
确定
</el-button>
</template>
</Dialog>
<AppLinkSelectDialog
ref="appLinkDialogRef"
@app-link-change="handleAppLinkChange"
/>
</template>
<style scoped lang="scss">
.hot-zone {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--el-color-primary);
cursor: move;
background: var(--el-color-primary-light-7);
border: 1px solid var(--el-color-primary);
opacity: 0.8;
/* 控制点 */
.ctrl-dot {
position: absolute;
z-index: 11;
width: 8px;
height: 8px;
background-color: #fff;
border: inherit;
border-radius: 50%;
}
.delete {
position: absolute;
top: 0;
right: 0;
display: none;
padding: 2px 2px 6px 6px;
color: #fff;
text-align: right;
cursor: pointer;
background-color: var(--el-color-primary);
border-radius: 0 0 0 80%;
}
&:hover {
.delete {
display: block;
}
}
}
</style>

View File

@ -0,0 +1,46 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 热区属性 */
export interface HotZoneProperty {
// 图片地址
imgUrl: string;
// 导航菜单列表
list: HotZoneItemProperty[];
// 组件样式
style: ComponentStyle;
}
/** 热区项目属性 */
export interface HotZoneItemProperty {
// 链接的名称
name: string;
// 链接
url: string;
// 宽
width: number;
// 高
height: number;
// 上
top: number;
// 左
left: number;
}
// 定义组件
export const component = {
id: 'HotZone',
name: '热区',
icon: 'tabler:hand-click',
property: {
imgUrl: '',
list: [] as HotZoneItemProperty[],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<HotZoneProperty>;

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { HotZoneProperty } from './config';
/** 热区 */
defineOptions({ name: 'HotZone' });
const props = defineProps<{ property: HotZoneProperty }>();
</script>
<template>
<div class="min-h-30px relative h-full w-full">
<el-image
:src="props.property.imgUrl"
class="pointer-events-none h-full w-full select-none"
/>
<div
v-for="(item, index) in props.property.list"
:key="index"
class="hot-zone"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`,
}"
>
{{ item.name }}
</div>
</div>
</template>
<style scoped lang="scss">
.hot-zone {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--el-color-primary);
cursor: move;
background: var(--el-color-primary-light-7);
border: 1px solid var(--el-color-primary);
opacity: 0.8;
}
</style>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import type { HotZoneProperty } from './config';
import { ref } from 'vue';
import { useVModel } from '@vueuse/core';
import HotZoneEditDialog from './components/HotZoneEditDialog/index.vue';
/** 热区属性面板 */
defineOptions({ name: 'HotZoneProperty' });
const props = defineProps<{ modelValue: HotZoneProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const editDialogRef = ref();
//
const handleOpenEditDialog = () => {
editDialogRef.value.open();
};
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="上传图片" prop="imgUrl">
<UploadImg
v-model="formData.imgUrl"
height="50px"
width="auto"
class="min-w-80px"
>
<template #tip>
<el-text type="info" size="small"> 推荐宽度 750</el-text>
</template>
</UploadImg>
</el-form-item>
</el-form>
<el-button
type="primary"
plain
class="w-full"
@click="handleOpenEditDialog"
>
设置热区
</el-button>
</ComponentContainerProperty>
<!-- 热区编辑对话框 -->
<HotZoneEditDialog
ref="editDialogRef"
v-model="formData.list"
:img-url="formData.imgUrl"
/>
</template>
<style scoped lang="scss">
.hot-zone {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
cursor: move;
background: #409effbf;
border: 1px solid var(--el-color-primary);
/* 控制点 */
.ctrl-dot {
position: absolute;
width: 4px;
height: 4px;
background-color: #fff;
border-radius: 50%;
}
}
</style>

View File

@ -0,0 +1,30 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 图片展示属性 */
export interface ImageBarProperty {
// 图片链接
imgUrl: string;
// 跳转链接
url: string;
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'ImageBar',
name: '图片展示',
icon: 'ep:picture',
property: {
imgUrl: '',
url: '',
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<ImageBarProperty>;

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ImageBarProperty } from './config';
/** 图片展示 */
defineOptions({ name: 'ImageBar' });
defineProps<{ property: ImageBarProperty }>();
</script>
<template>
<!-- 无图片 -->
<div
class="h-50px bg-gray-3 flex items-center justify-center"
v-if="!property.imgUrl"
>
<Icon icon="ep:picture" class="text-gray-8 text-30px!" />
</div>
<el-image class="min-h-30px" v-else :src="property.imgUrl" />
</template>
<style scoped lang="scss">
/* 图片 */
img {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ImageBarProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'ImageBarProperty' });
const props = defineProps<{ modelValue: ImageBarProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-form-item label="上传图片" prop="imgUrl">
<UploadImg
v-model="formData.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
>
<template #tip> 建议宽度750 </template>
</UploadImg>
</el-form-item>
<el-form-item label="链接" prop="url">
<AppLinkInput v-model="formData.url" />
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,52 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 广告魔方属性 */
export interface MagicCubeProperty {
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间隔
space: number;
// 导航菜单列表
list: MagicCubeItemProperty[];
// 组件样式
style: ComponentStyle;
}
/** 广告魔方项目属性 */
export interface MagicCubeItemProperty {
// 图标链接
imgUrl: string;
// 链接
url: string;
// 宽
width: number;
// 高
height: number;
// 上
top: number;
// 左
left: number;
}
// 定义组件
export const component = {
id: 'MagicCube',
name: '广告魔方',
icon: 'bi:columns',
property: {
borderRadiusTop: 0,
borderRadiusBottom: 0,
space: 0,
list: [],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<MagicCubeProperty>;

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import type { MagicCubeProperty } from './config';
import { computed } from 'vue';
/** 广告魔方 */
defineOptions({ name: 'MagicCube' });
const props = defineProps<{ property: MagicCubeProperty }>();
//
const CUBE_SIZE = 93.75;
/**
* 计算方块的行数
* 行数用于计算魔方的总体高度存在以下情况
* 1. 没有数据时默认就只显示一行的高度
* 2. 底部的空白不算高度例如只有第一行有数据那么就只显示一行的高度
* 3. 顶部及中间的空白算高度例如一共有四行只有最后一行有数据那么也显示四行的高度
*/
const rowCount = computed(() => {
let count = 0;
if (props.property.list.length > 0) {
//
count = Math.max(
...props.property.list.map((item) => item.top + item.height),
);
}
//
return count === 0 ? 1 : count;
});
</script>
<template>
<div
class="relative"
:style="{
height: `${rowCount * CUBE_SIZE}px`,
width: `${4 * CUBE_SIZE}px`,
padding: `${property.space}px`,
}"
>
<div
v-for="(item, index) in property.list"
:key="index"
class="absolute"
:style="{
width: `${item.width * CUBE_SIZE - property.space}px`,
height: `${item.height * CUBE_SIZE - property.space}px`,
top: `${item.top * CUBE_SIZE}px`,
left: `${item.left * CUBE_SIZE}px`,
}"
>
<el-image
class="h-full w-full"
fit="cover"
:src="item.imgUrl"
:style="{
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
>
<template #error>
<div class="image-slot">
<div
class="flex items-center justify-center"
:style="{
width: `${item.width * CUBE_SIZE}px`,
height: `${item.height * CUBE_SIZE}px`,
}"
>
<Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
</div>
</div>
</template>
</el-image>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import type { MagicCubeProperty } from './config';
import { ref } from 'vue';
import { useVModel } from '@vueuse/core';
/** 广告魔方属性面板 */
defineOptions({ name: 'MagicCubeProperty' });
const props = defineProps<{ modelValue: MagicCubeProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const selectedHotAreaIndex = ref(-1);
const handleHotAreaSelected = (_: any, index: number) => {
selectedHotAreaIndex.value = index;
};
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-text tag="p"> 魔方设置 </el-text>
<el-text type="info" size="small"> 每格尺寸187 * 187 </el-text>
<MagicCubeEditor
class="m-y-16px"
v-model="formData.list"
:rows="4"
:cols="4"
@hot-area-selected="handleHotAreaSelected"
/>
<template v-for="(hotArea, index) in formData.list" :key="index">
<template v-if="selectedHotAreaIndex === index">
<el-form-item label="上传图片" :prop="`list[${index}].imgUrl`">
<UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" />
</el-form-item>
<el-form-item label="链接" :prop="`list[${index}].url`">
<AppLinkInput v-model="hotArea.url" />
</el-form-item>
</template>
</template>
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,83 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
import { cloneDeep } from '@vben/utils';
/** 宫格导航属性 */
export interface MenuGridProperty {
// 列数
column: number;
// 导航菜单列表
list: MenuGridItemProperty[];
// 组件样式
style: ComponentStyle;
}
/** 宫格导航项目属性 */
export interface MenuGridItemProperty {
// 图标链接
iconUrl: string;
// 标题
title: string;
// 标题颜色
titleColor: string;
// 副标题
subtitle: string;
// 副标题颜色
subtitleColor: string;
// 链接
url: string;
// 角标
badge: {
// 角标背景颜色
bgColor: string;
// 是否显示
show: boolean;
// 角标文字
text: string;
// 角标文字颜色
textColor: string;
};
}
export const EMPTY_MENU_GRID_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
subtitle: '副标题',
subtitleColor: '#bbb',
badge: {
show: false,
textColor: '#fff',
bgColor: '#FF6000',
},
} as MenuGridItemProperty;
// 定义组件
export const component = {
id: 'MenuGrid',
name: '宫格导航',
icon: 'bi:grid-3x3-gap',
property: {
column: 3,
list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
marginLeft: 8,
marginRight: 8,
padding: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8,
borderRadius: 8,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
borderBottomLeftRadius: 8,
} as ComponentStyle,
},
} as DiyComponent<MenuGridProperty>;

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { MenuGridProperty } from './config';
/** 宫格导航 */
defineOptions({ name: 'MenuGrid' });
defineProps<{ property: MenuGridProperty }>();
</script>
<template>
<div class="flex flex-row flex-wrap">
<div
v-for="(item, index) in property.list"
:key="index"
class="p-b-14px p-t-20px relative flex flex-col items-center"
:style="{ width: `${100 * (1 / property.column)}%` }"
>
<!-- 右上角角标 -->
<span
v-if="item.badge?.show"
class="left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-12px leading-20px absolute text-center"
:style="{
color: item.badge.textColor,
backgroundColor: item.badge.bgColor,
}"
>
{{ item.badge.text }}
</span>
<el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" />
<span
class="m-t-8px h-16px text-12px leading-16px"
:style="{ color: item.titleColor }"
>
{{ item.title }}
</span>
<span
class="m-t-6px h-12px text-10px leading-12px"
:style="{ color: item.subtitleColor }"
>
{{ item.subtitle }}
</span>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import type { MenuGridProperty } from './config';
import { useVModel } from '@vueuse/core';
import { EMPTY_MENU_GRID_ITEM_PROPERTY } from './config';
/** 宫格导航属性面板 */
defineOptions({ name: 'MenuGridProperty' });
const props = defineProps<{ modelValue: MenuGridProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="每行数量" prop="column">
<el-radio-group v-model="formData.column">
<el-radio :value="3">3</el-radio>
<el-radio :value="4">4</el-radio>
</el-radio-group>
</el-form-item>
<el-card header="菜单设置" class="property-group" shadow="never">
<Draggable
v-model="formData.list"
:empty-item="EMPTY_MENU_GRID_ITEM_PROPERTY"
>
<template #default="{ element }">
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸44 * 44 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor
v-model="element.title"
v-model:color="element.titleColor"
/>
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<InputWithColor
v-model="element.subtitle"
v-model:color="element.subtitleColor"
/>
</el-form-item>
<el-form-item label="链接" prop="url">
<AppLinkInput v-model="element.url" />
</el-form-item>
<el-form-item label="显示角标" prop="badge.show">
<el-switch v-model="element.badge.show" />
</el-form-item>
<template v-if="element.badge.show">
<el-form-item label="角标内容" prop="badge.text">
<InputWithColor
v-model="element.badge.text"
v-model:color="element.badge.textColor"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="badge.bgColor">
<ColorInput v-model="element.badge.bgColor" />
</el-form-item>
</template>
</template>
</Draggable>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,52 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
import { cloneDeep } from '@vben/utils';
/** 列表导航属性 */
export interface MenuListProperty {
// 导航菜单列表
list: MenuListItemProperty[];
// 组件样式
style: ComponentStyle;
}
/** 列表导航项目属性 */
export interface MenuListItemProperty {
// 图标链接
iconUrl: string;
// 标题
title: string;
// 标题颜色
titleColor: string;
// 副标题
subtitle: string;
// 副标题颜色
subtitleColor: string;
// 链接
url: string;
}
export const EMPTY_MENU_LIST_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
subtitle: '副标题',
subtitleColor: '#bbb',
};
// 定义组件
export const component = {
id: 'MenuList',
name: '列表导航',
icon: 'fa-solid:list',
property: {
list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<MenuListProperty>;

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import type { MenuListProperty } from './config';
/** 列表导航 */
defineOptions({ name: 'MenuList' });
defineProps<{ property: MenuListProperty }>();
</script>
<template>
<div class="min-h-42px flex flex-col">
<div
v-for="(item, index) in property.list"
:key="index"
class="item h-42px gap-4px p-x-12px flex flex-row items-center justify-between"
>
<div class="gap-8px flex flex-1 flex-row items-center">
<el-image
v-if="item.iconUrl"
class="h-16px w-16px"
:src="item.iconUrl"
/>
<span class="text-16px" :style="{ color: item.titleColor }">{{
item.title
}}</span>
</div>
<div class="item-center gap-4px flex flex-row justify-center">
<span class="text-12px" :style="{ color: item.subtitleColor }">{{
item.subtitle
}}</span>
<Icon icon="ep-arrow-right" color="#000" :size="16" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.item + .item {
border-top: 1px solid #eee;
}
</style>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import type { MenuListProperty } from './config';
import { useVModel } from '@vueuse/core';
import { EMPTY_MENU_LIST_ITEM_PROPERTY } from './config';
/** 列表导航属性面板 */
defineOptions({ name: 'MenuListProperty' });
const props = defineProps<{ modelValue: MenuListProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-text tag="p"> 菜单设置 </el-text>
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
<!-- 表单 -->
<el-form label-width="60px" :model="formData" class="m-t-8px">
<Draggable
v-model="formData.list"
:empty-item="EMPTY_MENU_LIST_ITEM_PROPERTY"
>
<template #default="{ element }">
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸44 * 44 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor
v-model="element.title"
v-model:color="element.titleColor"
/>
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<InputWithColor
v-model="element.subtitle"
v-model:color="element.subtitleColor"
/>
</el-form-item>
<el-form-item label="链接" prop="url">
<AppLinkInput v-model="element.url" />
</el-form-item>
</template>
</Draggable>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,70 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
import { cloneDeep } from '@vben/utils';
/** 菜单导航属性 */
export interface MenuSwiperProperty {
// 布局: 图标+文字 | 图标
layout: 'icon' | 'iconText';
// 行数
row: number;
// 列数
column: number;
// 导航菜单列表
list: MenuSwiperItemProperty[];
// 组件样式
style: ComponentStyle;
}
/** 菜单导航项目属性 */
export interface MenuSwiperItemProperty {
// 图标链接
iconUrl: string;
// 标题
title: string;
// 标题颜色
titleColor: string;
// 链接
url: string;
// 角标
badge: {
// 角标背景颜色
bgColor: string;
// 是否显示
show: boolean;
// 角标文字
text: string;
// 角标文字颜色
textColor: string;
};
}
export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = {
title: '标题',
titleColor: '#333',
badge: {
show: false,
textColor: '#fff',
bgColor: '#FF6000',
},
} as MenuSwiperItemProperty;
// 定义组件
export const component = {
id: 'MenuSwiper',
name: '菜单导航',
icon: 'bi:grid-3x2-gap',
property: {
layout: 'iconText',
row: 1,
column: 3,
list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<MenuSwiperProperty>;

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import type { MenuSwiperItemProperty, MenuSwiperProperty } from './config';
import { ref, watch } from 'vue';
/** 菜单导航 */
defineOptions({ name: 'MenuSwiper' });
const props = defineProps<{ property: MenuSwiperProperty }>();
//
const TITLE_HEIGHT = 20;
//
const ICON_SIZE = 32;
//
const SPACE_Y = 16;
//
const pages = ref<MenuSwiperItemProperty[][]>([]);
//
const carouselHeight = ref(0);
//
const rowHeight = ref(0);
//
const columnWidth = ref('');
watch(
() => props.property,
() => {
//
columnWidth.value = `${100 * (1 / props.property.column)}%`;
// + 0 + * 2
rowHeight.value =
(props.property.layout === 'iconText'
? ICON_SIZE + TITLE_HEIGHT
: ICON_SIZE) +
SPACE_Y * 2;
// *
carouselHeight.value = props.property.row * rowHeight.value;
// *
const pageSize = props.property.row * props.property.column;
//
pages.value = [];
//
let pageItems: MenuSwiperItemProperty[] = [];
for (const item of props.property.list) {
//
if (pageItems.length === pageSize) {
pageItems = [];
}
//
if (pageItems.length === 0) {
pages.value.push(pageItems);
}
//
pageItems.push(item);
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<el-carousel
:height="`${carouselHeight}px`"
:autoplay="false"
arrow="hover"
indicator-position="outside"
>
<el-carousel-item v-for="(page, pageIndex) in pages" :key="pageIndex">
<div class="flex flex-row flex-wrap">
<div
v-for="(item, index) in page"
:key="index"
class="relative flex flex-col items-center justify-center"
:style="{ width: columnWidth, height: `${rowHeight}px` }"
>
<!-- 图标 + 角标 -->
<div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`">
<!-- 右上角角标 -->
<span
v-if="item.badge?.show"
class="right--10px top--10px z-1 h-20px rounded-10px p-x-6px text-12px leading-20px absolute text-center"
:style="{
color: item.badge.textColor,
backgroundColor: item.badge.bgColor,
}"
>
{{ item.badge.text }}
</span>
<el-image
v-if="item.iconUrl"
:src="item.iconUrl"
class="h-full w-full"
/>
</div>
<!-- 标题 -->
<span
v-if="property.layout === 'iconText'"
class="text-12px"
:style="{
color: item.titleColor,
height: `${TITLE_HEIGHT}px`,
lineHeight: `${TITLE_HEIGHT}px`,
}"
>
{{ item.title }}
</span>
</div>
</div>
</el-carousel-item>
</el-carousel>
</template>
<style lang="scss">
// APP
:root {
.el-carousel__indicator {
padding-top: 0;
padding-bottom: 0;
.el-carousel__button {
--el-carousel-indicator-height: 6px;
--el-carousel-indicator-width: 6px;
--el-carousel-indicator-out-color: #ff6000;
border-radius: 6px;
}
}
.el-carousel__indicator.is-active {
.el-carousel__button {
--el-carousel-indicator-width: 12px;
}
}
}
</style>

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import type { MenuSwiperProperty } from './config';
import { cloneDeep } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { EMPTY_MENU_SWIPER_ITEM_PROPERTY } from './config';
/** 菜单导航属性面板 */
defineOptions({ name: 'MenuSwiperProperty' });
const props = defineProps<{ modelValue: MenuSwiperProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="布局" prop="layout">
<el-radio-group v-model="formData.layout">
<el-radio value="iconText">图标+文字</el-radio>
<el-radio value="icon">仅图标</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="行数" prop="row">
<el-radio-group v-model="formData.row">
<el-radio :value="1">1</el-radio>
<el-radio :value="2">2</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="列数" prop="column">
<el-radio-group v-model="formData.column">
<el-radio :value="3">3</el-radio>
<el-radio :value="4">4</el-radio>
<el-radio :value="5">5</el-radio>
</el-radio-group>
</el-form-item>
<el-card header="菜单设置" class="property-group" shadow="never">
<Draggable
v-model="formData.list"
:empty-item="cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)"
>
<template #default="{ element }">
<el-form-item label="图标" prop="iconUrl">
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
<template #tip> 建议尺寸98 * 98 </template>
</UploadImg>
</el-form-item>
<el-form-item label="标题" prop="title">
<InputWithColor
v-model="element.title"
v-model:color="element.titleColor"
/>
</el-form-item>
<el-form-item label="链接" prop="url">
<AppLinkInput v-model="element.url" />
</el-form-item>
<el-form-item label="显示角标" prop="badge.show">
<el-switch v-model="element.badge.show" />
</el-form-item>
<template v-if="element.badge.show">
<el-form-item label="角标内容" prop="badge.text">
<InputWithColor
v-model="element.badge.text"
v-model:color="element.badge.textColor"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="badge.bgColor">
<ColorInput v-model="element.badge.bgColor" />
</el-form-item>
</template>
</template>
</Draggable>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,115 @@
<script lang="ts" setup>
import type { NavigationBarCellProperty } from '../config';
import { computed, ref } from 'vue';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'NavigationBarCellProperty' });
const props = defineProps({
isMp: {
type: Boolean,
default: true,
},
modelValue: {
type: Array as () => NavigationBarCellProperty[],
default: () => [],
},
});
const emit = defineEmits(['update:modelValue']);
const cellList = useVModel(props, 'modelValue', emit);
// 628
const cellCount = computed(() => (props.isMp ? 6 : 8));
//
const selectedHotAreaIndex = ref(0);
const handleHotAreaSelected = (
cellValue: NavigationBarCellProperty,
index: number,
) => {
selectedHotAreaIndex.value = index;
if (!cellValue.type) {
cellValue.type = 'text';
cellValue.textColor = '#111111';
}
};
</script>
<template>
<div class="h-40px flex items-center justify-center">
<MagicCubeEditor
v-model="cellList"
:cols="cellCount"
:cube-size="38"
:rows="1"
class="m-b-16px"
@hot-area-selected="handleHotAreaSelected"
/>
<img
v-if="isMp"
alt=""
class="h-30px w-76px"
src="@/assets/imgs/diy/app-nav-bar-mp.png"
/>
</div>
<template v-for="(cell, cellIndex) in cellList" :key="cellIndex">
<template v-if="selectedHotAreaIndex === Number(cellIndex)">
<el-form-item :prop="`cell[${cellIndex}].type`" label="类型">
<el-radio-group v-model="cell.type">
<el-radio value="text">文字</el-radio>
<el-radio value="image">图片</el-radio>
<el-radio value="search">搜索框</el-radio>
</el-radio-group>
</el-form-item>
<!-- 1. 文字 -->
<template v-if="cell.type === 'text'">
<el-form-item :prop="`cell[${cellIndex}].text`" label="内容">
<el-input v-model="cell!.text" maxlength="10" show-word-limit />
</el-form-item>
<el-form-item :prop="`cell[${cellIndex}].text`" label="颜色">
<ColorInput v-model="cell!.textColor" />
</el-form-item>
<el-form-item :prop="`cell[${cellIndex}].url`" label="链接">
<AppLinkInput v-model="cell.url" />
</el-form-item>
</template>
<!-- 2. 图片 -->
<template v-else-if="cell.type === 'image'">
<el-form-item :prop="`cell[${cellIndex}].imgUrl`" label="图片">
<UploadImg
v-model="cell.imgUrl"
:limit="1"
height="56px"
width="56px"
>
<template #tip>建议尺寸 56*56</template>
</UploadImg>
</el-form-item>
<el-form-item :prop="`cell[${cellIndex}].url`" label="链接">
<AppLinkInput v-model="cell.url" />
</el-form-item>
</template>
<!-- 3. 搜索框 -->
<template v-else>
<el-form-item :prop="`cell[${cellIndex}].placeholder`" label="提示文字">
<el-input v-model="cell.placeholder" maxlength="10" show-word-limit />
</el-form-item>
<el-form-item :prop="`cell[${cellIndex}].borderRadius`" label="圆角">
<el-slider
v-model="cell.borderRadius"
:max="100"
:min="0"
:show-input-controls="false"
input-size="small"
show-input
/>
</el-form-item>
</template>
</template>
</template>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,82 @@
import type { DiyComponent } from '#/components/diy-editor/util';
/** 顶部导航栏属性 */
export interface NavigationBarProperty {
// 背景类型
bgType: 'color' | 'img';
// 背景颜色
bgColor: string;
// 图片链接
bgImg: string;
// 样式类型:默认 | 沉浸式
styleType: 'inner' | 'normal';
// 常驻显示
alwaysShow: boolean;
// 小程序单元格列表
mpCells: NavigationBarCellProperty[];
// 其它平台单元格列表
otherCells: NavigationBarCellProperty[];
// 本地变量
_local: {
// 预览顶部导航(小程序)
previewMp: boolean;
// 预览顶部导航(非小程序)
previewOther: boolean;
};
}
/** 顶部导航栏 - 单元格 属性 */
export interface NavigationBarCellProperty {
// 类型:文字 | 图片 | 搜索框
type: 'image' | 'search' | 'text';
// 宽度
width: number;
// 高度
height: number;
// 顶部位置
top: number;
// 左侧位置
left: number;
// 文字内容
text: string;
// 文字颜色
textColor: string;
// 图片地址
imgUrl: string;
// 图片链接
url: string;
// 搜索框:提示文字
placeholder: string;
// 搜索框:边框圆角半径
borderRadius: number;
}
// 定义组件
export const component = {
id: 'NavigationBar',
name: '顶部导航栏',
icon: 'tabler:layout-navbar',
property: {
bgType: 'color',
bgColor: '#fff',
bgImg: '',
styleType: 'normal',
alwaysShow: true,
mpCells: [
{
type: 'text',
textColor: '#111111',
},
],
otherCells: [
{
type: 'text',
textColor: '#111111',
},
],
_local: {
previewMp: true,
previewOther: false,
},
},
} as DiyComponent<NavigationBarProperty>;

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import type { StyleValue } from 'vue';
import type {
NavigationBarCellProperty,
NavigationBarProperty,
} from './config';
import type { SearchProperty } from '#/components/diy-editor/components/mobile/SearchBar/config';
import { computed } from 'vue';
import SearchBar from '#/components/diy-editor/components/mobile/SearchBar/index.vue';
/** 页面顶部导航栏 */
defineOptions({ name: 'NavigationBar' });
const props = defineProps<{ property: NavigationBarProperty }>();
//
const bgStyle = computed(() => {
const background =
props.property.bgType === 'img' && props.property.bgImg
? `url(${props.property.bgImg}) no-repeat top center / 100% 100%`
: props.property.bgColor;
return { background };
});
//
const cellList = computed(() =>
props.property._local?.previewMp
? props.property.mpCells
: props.property.otherCells,
);
//
const cellWidth = computed(() => {
return props.property._local?.previewMp
? (375 - 80 - 86) / 6
: (375 - 90) / 8;
});
//
const getCellStyle = (cell: NavigationBarCellProperty) => {
return {
width: `${cell.width * cellWidth.value + (cell.width - 1) * 10}px`,
left: `${cell.left * cellWidth.value + (cell.left + 1) * 10}px`,
position: 'absolute',
} as StyleValue;
};
//
const getSearchProp = computed(() => (cell: NavigationBarCellProperty) => {
return {
height: 30,
showScan: false,
placeholder: cell.placeholder,
borderRadius: cell.borderRadius,
} as SearchProperty;
});
</script>
<template>
<div class="navigation-bar" :style="bgStyle">
<div class="flex h-full w-full items-center">
<div
v-for="(cell, cellIndex) in cellList"
:key="cellIndex"
:style="getCellStyle(cell)"
>
<span v-if="cell.type === 'text'">{{ cell.text }}</span>
<img
v-else-if="cell.type === 'image'"
:src="cell.imgUrl"
alt=""
class="h-full w-full"
/>
<SearchBar v-else :property="getSearchProp(cell)" />
</div>
</div>
<img
v-if="property._local?.previewMp"
src="@/assets/imgs/diy/app-nav-bar-mp.png"
alt=""
class="h-30px w-86px"
/>
</div>
</template>
<style lang="scss" scoped>
.navigation-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 6px;
background: #fff;
/* 左边 */
.left {
margin-left: 8px;
}
.center {
flex: 1;
font-size: 14px;
line-height: 35px;
color: #333;
text-align: center;
}
/* 右边 */
.right {
margin-right: 8px;
}
}
</style>

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import type { NavigationBarProperty } from './config';
import { useVModel } from '@vueuse/core';
import NavigationBarCellProperty from './components/CellProperty.vue';
//
defineOptions({ name: 'NavigationBarProperty' });
const props = defineProps<{ modelValue: NavigationBarProperty }>();
const emit = defineEmits(['update:modelValue']);
//
const rules = {
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }],
};
const formData = useVModel(props, 'modelValue', emit);
if (!formData.value._local) {
formData.value._local = { previewMp: true, previewOther: false };
}
</script>
<template>
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="样式" prop="styleType">
<el-radio-group v-model="formData!.styleType">
<el-radio value="normal">标准</el-radio>
<el-tooltip
content="沉侵式头部仅支持微信小程序、APP建议页面第一个组件为图片展示类组件"
placement="top"
>
<el-radio value="inner">沉浸式</el-radio>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item
label="常驻显示"
prop="alwaysShow"
v-if="formData.styleType === 'inner'"
>
<el-radio-group v-model="formData!.alwaysShow">
<el-radio :value="false">关闭</el-radio>
<el-tooltip
content="常驻显示关闭后,头部小组件将在页面滑动时淡入"
placement="top"
>
<el-radio :value="true">开启</el-radio>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="背景类型" prop="bgType">
<el-radio-group v-model="formData.bgType">
<el-radio value="color">纯色</el-radio>
<el-radio value="img">图片</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="背景颜色"
prop="bgColor"
v-if="formData.bgType === 'color'"
>
<ColorInput v-model="formData.bgColor" />
</el-form-item>
<el-form-item label="背景图片" prop="bgImg" v-else>
<div class="flex items-center">
<UploadImg
v-model="formData.bgImg"
:limit="1"
width="56px"
height="56px"
/>
<span class="mb-2 ml-2 text-xs text-gray-400">建议宽度750</span>
</div>
</el-form-item>
<el-card class="property-group" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span>内容小程序</span>
<el-form-item prop="_local.previewMp" class="m-b-0!">
<el-checkbox
v-model="formData._local.previewMp"
@change="
formData._local.previewOther = !formData._local.previewMp
"
>
预览
</el-checkbox>
</el-form-item>
</div>
</template>
<NavigationBarCellProperty v-model="formData.mpCells" is-mp />
</el-card>
<el-card class="property-group" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span>内容非小程序</span>
<el-form-item prop="_local.previewOther" class="m-b-0!">
<el-checkbox
v-model="formData._local.previewOther"
@change="
formData._local.previewMp = !formData._local.previewOther
"
>
预览
</el-checkbox>
</el-form-item>
</div>
</template>
<NavigationBarCellProperty v-model="formData.otherCells" :is-mp="false" />
</el-card>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,49 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 公告栏属性 */
export interface NoticeBarProperty {
// 图标地址
iconUrl: string;
// 公告内容列表
contents: NoticeContentProperty[];
// 背景颜色
backgroundColor: string;
// 文字颜色
textColor: string;
// 组件样式
style: ComponentStyle;
}
/** 内容属性 */
export interface NoticeContentProperty {
// 内容文字
text: string;
// 链接地址
url: string;
}
// 定义组件
export const component = {
id: 'NoticeBar',
name: '公告栏',
icon: 'ep:bell',
property: {
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/xinjian.png',
contents: [
{
text: '',
url: '',
},
],
backgroundColor: '#fff',
textColor: '#333',
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<NoticeBarProperty>;

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import type { NoticeBarProperty } from './config';
/** 公告栏 */
defineOptions({ name: 'NoticeBar' });
defineProps<{ property: NoticeBarProperty }>();
</script>
<template>
<div
class="p-y-4px text-12px flex items-center"
:style="{
backgroundColor: property.backgroundColor,
color: property.textColor,
}"
>
<el-image :src="property.iconUrl" class="h-18px" />
<el-divider direction="vertical" />
<el-carousel
height="24px"
direction="vertical"
:autoplay="true"
class="p-r-8px flex-1"
>
<el-carousel-item v-for="(item, index) in property.contents" :key="index">
<div class="h-24px leading-24px truncate">{{ item.text }}</div>
</el-carousel-item>
</el-carousel>
<Icon icon="ep:arrow-right" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { NoticeBarProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'NoticeBarProperty' });
const props = defineProps<{ modelValue: NoticeBarProperty }>();
const emit = defineEmits(['update:modelValue']);
//
const rules = {
content: [{ required: true, message: '请输入公告', trigger: 'blur' }],
};
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="公告图标" prop="iconUrl">
<UploadImg v-model="formData.iconUrl" height="48px">
<template #tip>建议尺寸24 * 24</template>
</UploadImg>
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</el-form-item>
<el-form-item label="文字颜色" prop="文字颜色">
<ColorInput v-model="formData.textColor" />
</el-form-item>
<el-card header="公告内容" class="property-group" shadow="never">
<Draggable v-model="formData.contents">
<template #default="{ element }">
<el-form-item label="公告" prop="text" label-width="40px">
<el-input v-model="element.text" placeholder="请输入公告" />
</el-form-item>
<el-form-item label="链接" prop="url" label-width="40px">
<AppLinkInput v-model="element.url" />
</el-form-item>
</template>
</Draggable>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,23 @@
import type { DiyComponent } from '#/components/diy-editor/util';
/** 页面设置属性 */
export interface PageConfigProperty {
// 页面描述
description: string;
// 页面背景颜色
backgroundColor: string;
// 页面背景图片
backgroundImage: string;
}
// 定义页面组件
export const component = {
id: 'PageConfig',
name: '页面设置',
icon: 'ep:document',
property: {
description: '',
backgroundColor: '#f5f5f5',
backgroundImage: '',
},
} as DiyComponent<PageConfigProperty>;

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PageConfigProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'PageConfigProperty' });
const props = defineProps<{ modelValue: PageConfigProperty }>();
const emit = defineEmits(['update:modelValue']);
//
const rules = {};
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<el-form label-width="80px" :model="formData" :rules="rules">
<el-form-item label="页面描述" prop="description">
<el-input
type="textarea"
v-model="formData!.description"
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
/>
</el-form-item>
<el-form-item label="背景颜色" prop="backgroundColor">
<ColorInput v-model="formData!.backgroundColor" />
</el-form-item>
<el-form-item label="背景图片" prop="backgroundImage">
<UploadImg v-model="formData!.backgroundImage" :limit="1">
<template #tip>建议宽度 750px</template>
</UploadImg>
</el-form-item>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,26 @@
import type { DiyComponent } from '#/components/diy-editor/util';
/** 弹窗广告属性 */
export interface PopoverProperty {
list: PopoverItemProperty[];
}
export interface PopoverItemProperty {
// 图片地址
imgUrl: string;
// 跳转连接
url: string;
// 显示类型:仅显示一次、每次启动都会显示
showType: 'always' | 'once';
}
// 定义组件
export const component = {
id: 'Popover',
name: '弹窗广告',
icon: 'carbon:popup',
position: 'fixed',
property: {
list: [{ showType: 'once' }],
},
} as DiyComponent<PopoverProperty>;

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PopoverProperty } from './config';
import { ref } from 'vue';
/** 弹窗广告 */
defineOptions({ name: 'Popover' });
//
defineProps<{ property: PopoverProperty }>();
//
const activeIndex = ref(0);
const handleActive = (index: number) => {
activeIndex.value = index;
};
</script>
<template>
<div
v-for="(item, index) in property.list"
:key="index"
class="bottom-50% right-50% h-454px w-292px border-1px border-gray border-rounded-4px p-1px absolute border-solid bg-white"
:style="{
zIndex: 100 + index + (activeIndex === index ? 100 : 0),
marginRight: `${-146 - index * 20}px`,
marginBottom: `${-227 - index * 20}px`,
}"
@click="handleActive(index)"
>
<el-image :src="item.imgUrl" fit="contain" class="h-full w-full">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<Icon icon="ep:picture" />
</div>
</template>
</el-image>
<div class="text-12px absolute right-1 top-1">{{ index + 1 }}</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import type { PopoverProperty } from './config';
import { useVModel } from '@vueuse/core';
// 广
defineOptions({ name: 'PopoverProperty' });
const props = defineProps<{ modelValue: PopoverProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<el-form label-width="80px" :model="formData">
<Draggable v-model="formData.list" :empty-item="{ showType: 'once' }">
<template #default="{ element, index }">
<el-form-item label="图片" :prop="`list[${index}].imgUrl`">
<UploadImg v-model="element.imgUrl" height="56px" width="56px" />
</el-form-item>
<el-form-item label="跳转链接" :prop="`list[${index}].url`">
<AppLinkInput v-model="element.url" />
</el-form-item>
<el-form-item label="显示次数" :prop="`list[${index}].showType`">
<el-radio-group v-model="element.showType">
<el-tooltip
content="只显示一次,下次打开时不显示"
placement="bottom"
>
<el-radio value="once">一次</el-radio>
</el-tooltip>
<el-tooltip content="每次打开时都会显示" placement="bottom">
<el-radio value="always">不限</el-radio>
</el-tooltip>
</el-radio-group>
</el-form-item>
</template>
</Draggable>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,100 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 商品卡片属性 */
export interface ProductCardProperty {
// 布局类型:单列大图 | 单列小图 | 双列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
// 商品字段
fields: {
// 商品简介
introduction: ProductCardFieldProperty;
// 商品市场价
marketPrice: ProductCardFieldProperty;
// 商品名称
name: ProductCardFieldProperty;
// 商品价格
price: ProductCardFieldProperty;
// 商品销量
salesCount: ProductCardFieldProperty;
// 商品库存
stock: ProductCardFieldProperty;
};
// 角标
badge: {
// 角标图片
imgUrl: string;
// 是否显示
show: boolean;
};
// 按钮
btnBuy: {
// 文字按钮:背景渐变起始颜色
bgBeginColor: string;
// 文字按钮:背景渐变结束颜色
bgEndColor: string;
// 图片按钮:图片地址
imgUrl: string;
// 文字
text: string;
// 类型:文字 | 图片
type: 'img' | 'text';
};
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间距
space: number;
// 商品编号列表
spuIds: number[];
// 组件样式
style: ComponentStyle;
}
// 商品字段
export interface ProductCardFieldProperty {
// 是否显示
show: boolean;
// 颜色
color: string;
}
// 定义组件
export const component = {
id: 'ProductCard',
name: '商品卡片',
icon: 'fluent:text-column-two-left-24-filled',
property: {
layoutType: 'oneColBigImg',
fields: {
name: { show: true, color: '#000' },
introduction: { show: true, color: '#999' },
price: { show: true, color: '#ff3000' },
marketPrice: { show: true, color: '#c4c4c4' },
salesCount: { show: true, color: '#c4c4c4' },
stock: { show: false, color: '#c4c4c4' },
},
badge: { show: false, imgUrl: '' },
btnBuy: {
type: 'text',
text: '立即购买',
// todo: @owen 根据主题色配置
bgBeginColor: '#FF6000',
bgEndColor: '#FE832A',
imgUrl: '',
},
borderRadiusTop: 6,
borderRadiusBottom: 6,
space: 8,
spuIds: [],
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<ProductCardProperty>;

View File

@ -0,0 +1,188 @@
<script setup lang="ts">
import type { ProductCardProperty } from './config';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { ref, watch } from 'vue';
import { fenToYuan } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
/** 商品卡片 */
defineOptions({ name: 'ProductCard' });
//
const props = defineProps<{ property: ProductCardProperty }>();
//
const spuList = ref<MallSpuApi.Spu[]>([]);
watch(
() => props.property.spuIds,
async () => {
spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds);
},
{
immediate: true,
deep: true,
},
);
/**
* 计算商品的间距
* @param index 商品索引
*/
const calculateSpace = (index: number) => {
//
const columns = props.property.layoutType === 'twoCol' ? 2 : 1;
//
const marginLeft = index % columns === 0 ? '0' : `${props.property.space}px`;
//
const marginTop = index < columns ? '0' : `${props.property.space}px`;
return { marginLeft, marginTop };
};
//
const containerRef = ref();
//
const calculateWidth = () => {
let width = '100%';
// - / 2
if (props.property.layoutType === 'twoCol') {
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`;
}
return { width };
};
</script>
<template>
<div
class="min-h-30px box-content flex w-full flex-row flex-wrap"
ref="containerRef"
>
<div
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
:style="{
...calculateSpace(index),
...calculateWidth(),
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
v-for="(spu, index) in spuList"
:key="index"
>
<!-- 角标 -->
<div
v-if="property.badge.show && property.badge.imgUrl"
class="z-1 absolute left-0 top-0 items-center justify-center"
>
<el-image
fit="cover"
:src="property.badge.imgUrl"
class="h-26px w-38px"
/>
</div>
<!-- 商品封面图 -->
<div
class="h-140px"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg',
},
]"
>
<el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="gap-8px p-8px box-border flex flex-col"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]':
property.layoutType === 'oneColSmallImg',
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-14px"
:class="[
{
truncate: property.layoutType !== 'oneColSmallImg',
'line-clamp-2 overflow-ellipsis':
property.layoutType === 'oneColSmallImg',
},
]"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<!-- 商品简介 -->
<div
v-if="property.fields.introduction.show"
class="text-12px truncate"
:style="{ color: property.fields.introduction.color }"
>
{{ spu.introduction }}
</div>
<div>
<!-- 价格 -->
<span
v-if="property.fields.price.show"
class="text-16px"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price as any) }}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
class="ml-4px text-10px line-through"
:style="{ color: property.fields.marketPrice.color }"
>{{ fenToYuan(spu.marketPrice) }}
</span>
</div>
<div class="text-12px">
<!-- 销量 -->
<span
v-if="property.fields.salesCount.show"
:style="{ color: property.fields.salesCount.color }"
>
已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}
</span>
<!-- 库存 -->
<span
v-if="property.fields.stock.show"
:style="{ color: property.fields.stock.color }"
>
库存{{ spu.stock || 0 }}
</span>
</div>
</div>
<!-- 购买按钮 -->
<div class="bottom-8px right-8px absolute">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="p-x-12px p-y-4px text-12px rounded-full text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
class="h-28px w-28px rounded-full"
fit="cover"
:src="property.btnBuy.imgUrl"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,159 @@
<script setup lang="ts">
import type { ProductCardProperty } from './config';
import { useVModel } from '@vueuse/core';
import SpuShowcase from '#/views/mall/product/spu/components/spu-showcase.vue';
//
defineOptions({ name: 'ProductCardProperty' });
const props = defineProps<{ modelValue: ProductCardProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="商品列表" class="property-group" shadow="never">
<SpuShowcase v-model="formData.spuIds" />
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="单列大图" placement="bottom">
<el-radio-button value="oneColBigImg">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="单列小图" placement="bottom">
<el-radio-button value="oneColSmallImg">
<Icon icon="fluent:text-column-two-left-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button value="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品简介" prop="fields.introduction.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.introduction.color" />
<el-checkbox v-model="formData.fields.introduction.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
<el-form-item label="市场价" prop="fields.marketPrice.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.marketPrice.color" />
<el-checkbox v-model="formData.fields.marketPrice.show" />
</div>
</el-form-item>
<el-form-item label="商品销量" prop="fields.salesCount.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.salesCount.color" />
<el-checkbox v-model="formData.fields.salesCount.show" />
</div>
</el-form-item>
<el-form-item label="商品库存" prop="fields.stock.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.stock.color" />
<el-checkbox v-model="formData.fields.stock.show" />
</div>
</el-form-item>
</el-card>
<el-card header="角标" class="property-group" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item
label="角标"
prop="badge.imgUrl"
v-if="formData.badge.show"
>
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22 </template>
</UploadImg>
</el-form-item>
</el-card>
<el-card header="按钮" class="property-group" shadow="never">
<el-form-item label="按钮类型" prop="btnBuy.type">
<el-radio-group v-model="formData.btnBuy.type">
<el-radio-button value="text">文字</el-radio-button>
<el-radio-button value="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="formData.btnBuy.type === 'text'">
<el-form-item label="按钮文字" prop="btnBuy.text">
<el-input v-model="formData.btnBuy.text" />
</el-form-item>
<el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</el-form-item>
<el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="图片" prop="btnBuy.imgUrl">
<UploadImg
v-model="formData.btnBuy.imgUrl"
height="56px"
width="56px"
>
<template #tip> 建议尺寸56 * 56 </template>
</UploadImg>
</el-form-item>
</template>
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,67 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 商品栏属性 */
export interface ProductListProperty {
// 布局类型:双列 | 三列 | 水平滑动
layoutType: 'horizSwiper' | 'threeCol' | 'twoCol';
// 商品字段
fields: {
// 商品名称
name: ProductListFieldProperty;
// 商品价格
price: ProductListFieldProperty;
};
// 角标
badge: {
// 角标图片
imgUrl: string;
// 是否显示
show: boolean;
};
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间距
space: number;
// 商品编号列表
spuIds: number[];
// 组件样式
style: ComponentStyle;
}
// 商品字段
export interface ProductListFieldProperty {
// 是否显示
show: boolean;
// 颜色
color: string;
}
// 定义组件
export const component = {
id: 'ProductList',
name: '商品栏',
icon: 'fluent:text-column-two-24-filled',
property: {
layoutType: 'twoCol',
fields: {
name: { show: true, color: '#000' },
price: { show: true, color: '#ff3000' },
},
badge: { show: false, imgUrl: '' },
borderRadiusTop: 8,
borderRadiusBottom: 8,
space: 8,
spuIds: [],
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<ProductListProperty>;

View File

@ -0,0 +1,148 @@
<script setup lang="ts">
import type { ProductListProperty } from './config';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { onMounted, ref, watch } from 'vue';
import { fenToYuan } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
/** 商品栏 */
defineOptions({ name: 'ProductList' });
//
const props = defineProps<{ property: ProductListProperty }>();
//
const spuList = ref<MallSpuApi.Spu[]>([]);
watch(
() => props.property.spuIds,
async () => {
spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds);
},
{
immediate: true,
deep: true,
},
);
//
const phoneWidth = ref(375);
//
const containerRef = ref();
//
const columns = ref(2);
//
const scrollbarWidth = ref('100%');
//
const imageSize = ref('0');
//
const gridTemplateColumns = ref('');
//
watch(
() => [props.property, phoneWidth, spuList.value.length],
() => {
//
columns.value = props.property.layoutType === 'twoCol' ? 2 : 3;
// - * ( - 1)/
const productWidth =
(phoneWidth.value - props.property.space * (columns.value - 1)) /
columns.value;
// 2 3
imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px`;
//
if (props.property.layoutType === 'horizSwiper') {
//
gridTemplateColumns.value = `repeat(auto-fill, ${productWidth}px)`;
//
scrollbarWidth.value = `${
productWidth * spuList.value.length +
props.property.space * (spuList.value.length - 1)
}px`;
} else {
//
gridTemplateColumns.value = `repeat(${columns.value}, auto)`;
//
scrollbarWidth.value = '100%';
}
},
{ immediate: true, deep: true },
);
onMounted(() => {
//
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375;
});
</script>
<template>
<el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef">
<!-- 商品网格 -->
<div
class="grid overflow-x-auto"
:style="{
gridGap: `${property.space}px`,
gridTemplateColumns,
width: scrollbarWidth,
}"
>
<!-- 商品 -->
<div
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
:style="{
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
v-for="(spu, index) in spuList"
:key="index"
>
<!-- 角标 -->
<div
v-if="property.badge.show"
class="z-1 absolute left-0 top-0 items-center justify-center"
>
<el-image
fit="cover"
:src="property.badge.imgUrl"
class="h-26px w-38px"
/>
</div>
<!-- 商品封面图 -->
<el-image
fit="cover"
:src="spu.picUrl"
:style="{ width: imageSize, height: imageSize }"
/>
<div
class="gap-8px p-8px box-border flex flex-col"
:class="[
{
'w-[calc(100%-64px)]': columns === 2,
'w-full': columns === 3,
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-12px truncate"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<div>
<!-- 商品价格 -->
<span
v-if="property.fields.price.show"
class="text-12px"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price || 0) }}
</span>
</div>
</div>
</div>
</div>
</el-scrollbar>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
import type { ProductListProperty } from './config';
import { useVModel } from '@vueuse/core';
import SpuShowcase from '#/views/mall/product/spu/components/spu-showcase.vue';
//
defineOptions({ name: 'ProductListProperty' });
const props = defineProps<{ modelValue: ProductListProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="商品列表" class="property-group" shadow="never">
<SpuShowcase v-model="formData.spuIds" />
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button value="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button value="threeCol">
<Icon icon="fluent:text-column-three-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="水平滑动" placement="bottom">
<el-radio-button value="horizSwiper">
<Icon icon="system-uicons:carousel" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
</el-card>
<el-card header="角标" class="property-group" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item
label="角标"
prop="badge.imgUrl"
v-if="formData.badge.show"
>
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22 </template>
</UploadImg>
</el-form-item>
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,28 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 营销文章属性 */
export interface PromotionArticleProperty {
// 文章编号
id: number;
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'PromotionArticle',
name: '营销文章',
icon: 'ph:article-medium',
property: {
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<PromotionArticleProperty>;

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PromotionArticleProperty } from './config';
import type { MallArticleApi } from '#/api/mall/promotion/article';
import { ref, watch } from 'vue';
import * as ArticleApi from '#/api/mall/promotion/article/index';
/** 营销文章 */
defineOptions({ name: 'PromotionArticle' });
//
const props = defineProps<{ property: PromotionArticleProperty }>();
//
const article = ref<MallArticleApi.Article>();
watch(
() => props.property.id,
async () => {
if (props.property.id) {
article.value = await ArticleApi.getArticle(props.property.id);
}
},
{
immediate: true,
},
);
</script>
<template>
<div class="min-h-30px" v-dompurify-html="article?.content"></div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { PromotionArticleProperty } from './config';
import type { MallArticleApi } from '#/api/mall/promotion/article';
import { onMounted, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import * as ArticleApi from '#/api/mall/promotion/article/index';
//
defineOptions({ name: 'PromotionArticleProperty' });
const props = defineProps<{ modelValue: PromotionArticleProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const articles = ref<MallArticleApi.Article[]>([]);
//
const loading = ref(false);
//
const queryArticleList = async (title?: string) => {
loading.value = true;
const { list } = await ArticleApi.getArticlePage({
title,
pageNo: 1,
pageSize: 10,
});
articles.value = list;
loading.value = false;
};
//
onMounted(() => {
queryArticleList();
});
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="40px" :model="formData">
<el-form-item label="文章" prop="id">
<el-select
v-model="formData.id"
placeholder="请选择文章"
class="w-full"
filterable
remote
:remote-method="queryArticleList"
:loading="loading"
>
<el-option
v-for="article in articles"
:key="article.id"
:label="article.title"
:value="article.id"
/>
</el-select>
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 拼团属性 */
export interface PromotionCombinationProperty {
// 布局类型:单列 | 三列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
// 商品字段
fields: {
// 商品简介
introduction: PromotionCombinationFieldProperty;
// 市场价
marketPrice: PromotionCombinationFieldProperty;
// 商品名称
name: PromotionCombinationFieldProperty;
// 商品价格
price: PromotionCombinationFieldProperty;
// 商品销量
salesCount: PromotionCombinationFieldProperty;
// 商品库存
stock: PromotionCombinationFieldProperty;
};
// 角标
badge: {
// 角标图片
imgUrl: string;
// 是否显示
show: boolean;
};
// 按钮
btnBuy: {
// 文字按钮:背景渐变起始颜色
bgBeginColor: string;
// 文字按钮:背景渐变结束颜色
bgEndColor: string;
// 图片按钮:图片地址
imgUrl: string;
// 文字
text: string;
// 类型:文字 | 图片
type: 'img' | 'text';
};
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间距
space: number;
// 拼团活动编号
activityIds: number[];
// 组件样式
style: ComponentStyle;
}
// 商品字段
export interface PromotionCombinationFieldProperty {
// 是否显示
show: boolean;
// 颜色
color: string;
}
// 定义组件
export const component = {
id: 'PromotionCombination',
name: '拼团',
icon: 'mdi:account-group',
property: {
layoutType: 'oneColBigImg',
fields: {
name: { show: true, color: '#000' },
introduction: { show: true, color: '#999' },
price: { show: true, color: '#ff3000' },
marketPrice: { show: true, color: '#c4c4c4' },
salesCount: { show: true, color: '#c4c4c4' },
stock: { show: false, color: '#c4c4c4' },
},
badge: { show: false, imgUrl: '' },
btnBuy: {
type: 'text',
text: '去拼团',
bgBeginColor: '#FF6000',
bgEndColor: '#FE832A',
imgUrl: '',
},
borderRadiusTop: 8,
borderRadiusBottom: 8,
space: 8,
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<PromotionCombinationProperty>;

View File

@ -0,0 +1,231 @@
<script setup lang="ts">
import type { PromotionCombinationProperty } from './config';
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import { ref, watch } from 'vue';
import { fenToYuan } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import * as CombinationActivityApi from '#/api/mall/promotion/combination/combinationActivity';
/** 拼团卡片 */
defineOptions({ name: 'PromotionCombination' });
//
const props = defineProps<{ property: PromotionCombinationProperty }>();
//
const spuList = ref<MallSpuApi.Spu[]>([]);
const spuIdList = ref<number[]>([]);
const combinationActivityList = ref<
MallCombinationActivityApi.CombinationActivity[]
>([]);
watch(
() => props.property.activityIds,
async () => {
try {
// ID
const activityIds = props.property.activityIds;
// ID
if (Array.isArray(activityIds) && activityIds.length > 0) {
//
combinationActivityList.value =
await CombinationActivityApi.getCombinationActivityListByIds(
activityIds,
);
// SPU
spuList.value = [];
spuIdList.value = combinationActivityList.value
.map((activity) => activity.spuId)
.filter((spuId): spuId is number => typeof spuId === 'number');
if (spuIdList.value.length > 0) {
spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value);
}
// SPU
combinationActivityList.value.forEach((activity) => {
// spuId
const spu = spuList.value.find((spu) => spu.id === activity.spuId);
if (spu) {
// 便
spu.price = Math.min(
activity.combinationPrice || Infinity,
spu.price || Infinity,
);
}
});
}
} catch (error) {
console.error('获取拼团活动细节或 SPU 细节时出错:', error);
}
},
{
immediate: true,
deep: true,
},
);
/**
* 计算商品的间距
* @param index 商品索引
*/
const calculateSpace = (index: number) => {
//
const columns = props.property.layoutType === 'twoCol' ? 2 : 1;
//
const marginLeft = index % columns === 0 ? '0' : `${props.property.space}px`;
//
const marginTop = index < columns ? '0' : `${props.property.space}px`;
return { marginLeft, marginTop };
};
//
const containerRef = ref();
//
const calculateWidth = () => {
let width = '100%';
// - / 2
if (props.property.layoutType === 'twoCol') {
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`;
}
return { width };
};
</script>
<template>
<div
class="min-h-30px box-content flex w-full flex-row flex-wrap"
ref="containerRef"
>
<div
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
:style="{
...calculateSpace(index),
...calculateWidth(),
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
v-for="(spu, index) in spuList"
:key="index"
>
<!-- 角标 -->
<div
v-if="property.badge.show"
class="z-1 absolute left-0 top-0 items-center justify-center"
>
<el-image
fit="cover"
:src="property.badge.imgUrl"
class="h-26px w-38px"
/>
</div>
<!-- 商品封面图 -->
<div
class="h-140px"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg',
},
]"
>
<el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="gap-8px p-8px box-border flex flex-col"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]':
property.layoutType === 'oneColSmallImg',
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-14px"
:class="[
{
truncate: property.layoutType !== 'oneColSmallImg',
'line-clamp-2 overflow-ellipsis':
property.layoutType === 'oneColSmallImg',
},
]"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<!-- 商品简介 -->
<div
v-if="property.fields.introduction.show"
class="text-12px truncate"
:style="{ color: property.fields.introduction.color }"
>
{{ spu.introduction }}
</div>
<div>
<!-- 价格 -->
<span
v-if="property.fields.price.show"
class="text-16px"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price || Infinity) }}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
class="ml-4px text-10px line-through"
:style="{ color: property.fields.marketPrice.color }"
>
{{ fenToYuan(spu.marketPrice) }}
</span>
</div>
<div class="text-12px">
<!-- 销量 -->
<span
v-if="property.fields.salesCount.show"
:style="{ color: property.fields.salesCount.color }"
>
已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}
</span>
<!-- 库存 -->
<span
v-if="property.fields.stock.show"
:style="{ color: property.fields.stock.color }"
>
库存{{ spu.stock || 0 }}
</span>
</div>
</div>
<!-- 购买按钮 -->
<div class="bottom-8px right-8px absolute">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="p-x-12px p-y-4px text-12px rounded-full text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
class="h-28px w-28px rounded-full"
fit="cover"
:src="property.btnBuy.imgUrl"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import type { PromotionCombinationProperty } from './config';
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import { onMounted, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import * as CombinationActivityApi from '#/api/mall/promotion/combination/combinationActivity';
import { CommonStatusEnum } from '#/utils';
import CombinationShowcase from '#/views/mall/promotion/combination/components/combination-showcase.vue';
//
defineOptions({ name: 'PromotionCombinationProperty' });
const props = defineProps<{ modelValue: PromotionCombinationProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const activityList = ref<MallCombinationActivityApi.CombinationActivity[]>([]);
onMounted(async () => {
const { list } = await CombinationActivityApi.getCombinationActivityPage({
pageNo: 1,
pageSize: 10,
status: CommonStatusEnum.ENABLE,
});
activityList.value = list;
});
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="拼团活动" class="property-group" shadow="never">
<CombinationShowcase v-model="formData.activityIds" />
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="单列大图" placement="bottom">
<el-radio-button value="oneColBigImg">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="单列小图" placement="bottom">
<el-radio-button value="oneColSmallImg">
<Icon icon="fluent:text-column-two-left-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button value="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
<!--<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button value="threeCol">
<Icon icon="fluent:text-column-three-24-filled" />
</el-radio-button>
</el-tooltip>-->
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品简介" prop="fields.introduction.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.introduction.color" />
<el-checkbox v-model="formData.fields.introduction.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
<el-form-item label="市场价" prop="fields.marketPrice.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.marketPrice.color" />
<el-checkbox v-model="formData.fields.marketPrice.show" />
</div>
</el-form-item>
<el-form-item label="商品销量" prop="fields.salesCount.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.salesCount.color" />
<el-checkbox v-model="formData.fields.salesCount.show" />
</div>
</el-form-item>
<el-form-item label="商品库存" prop="fields.stock.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.stock.color" />
<el-checkbox v-model="formData.fields.stock.show" />
</div>
</el-form-item>
</el-card>
<el-card header="角标" class="property-group" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item
label="角标"
prop="badge.imgUrl"
v-if="formData.badge.show"
>
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22</template>
</UploadImg>
</el-form-item>
</el-card>
<el-card header="按钮" class="property-group" shadow="never">
<el-form-item label="按钮类型" prop="btnBuy.type">
<el-radio-group v-model="formData.btnBuy.type">
<el-radio-button value="text">文字</el-radio-button>
<el-radio-button value="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="formData.btnBuy.type === 'text'">
<el-form-item label="按钮文字" prop="btnBuy.text">
<el-input v-model="formData.btnBuy.text" />
</el-form-item>
<el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</el-form-item>
<el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="图片" prop="btnBuy.imgUrl">
<UploadImg
v-model="formData.btnBuy.imgUrl"
height="56px"
width="56px"
>
<template #tip> 建议尺寸56 * 56</template>
</UploadImg>
</el-form-item>
</template>
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 积分商城属性 */
export interface PromotionPointProperty {
// 布局类型:单列 | 三列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
// 商品字段
fields: {
// 商品简介
introduction: PromotionPointFieldProperty;
// 市场价
marketPrice: PromotionPointFieldProperty;
// 商品名称
name: PromotionPointFieldProperty;
// 商品价格
price: PromotionPointFieldProperty;
// 商品销量
salesCount: PromotionPointFieldProperty;
// 商品库存
stock: PromotionPointFieldProperty;
};
// 角标
badge: {
// 角标图片
imgUrl: string;
// 是否显示
show: boolean;
};
// 按钮
btnBuy: {
// 文字按钮:背景渐变起始颜色
bgBeginColor: string;
// 文字按钮:背景渐变结束颜色
bgEndColor: string;
// 图片按钮:图片地址
imgUrl: string;
// 文字
text: string;
// 类型:文字 | 图片
type: 'img' | 'text';
};
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间距
space: number;
// 秒杀活动编号
activityIds: number[];
// 组件样式
style: ComponentStyle;
}
// 商品字段
export interface PromotionPointFieldProperty {
// 是否显示
show: boolean;
// 颜色
color: string;
}
// 定义组件
export const component = {
id: 'PromotionPoint',
name: '积分商城',
icon: 'ep:present',
property: {
layoutType: 'oneColBigImg',
fields: {
name: { show: true, color: '#000' },
introduction: { show: true, color: '#999' },
price: { show: true, color: '#ff3000' },
marketPrice: { show: true, color: '#c4c4c4' },
salesCount: { show: true, color: '#c4c4c4' },
stock: { show: false, color: '#c4c4c4' },
},
badge: { show: false, imgUrl: '' },
btnBuy: {
type: 'text',
text: '立即兑换',
bgBeginColor: '#FF6000',
bgEndColor: '#FE832A',
imgUrl: '',
},
borderRadiusTop: 8,
borderRadiusBottom: 8,
space: 8,
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<PromotionPointProperty>;

View File

@ -0,0 +1,232 @@
<script lang="ts" setup>
import type { PromotionPointProperty } from './config';
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { ref, watch } from 'vue';
import { fenToYuan } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import * as PointActivityApi from '#/api/mall/promotion/point';
/** 积分商城卡片 */
defineOptions({ name: 'PromotionPoint' });
//
const props = defineProps<{ property: PromotionPointProperty }>();
//
const spuList = ref<MallPointActivityApi.SpuExtensionWithPoint[]>([]);
const spuIdList = ref<number[]>([]);
const pointActivityList = ref<MallPointActivityApi.PointActivity[]>([]);
watch(
() => props.property.activityIds,
async () => {
try {
// ID
const activityIds = props.property.activityIds;
// ID
if (Array.isArray(activityIds) && activityIds.length > 0) {
//
pointActivityList.value =
await PointActivityApi.getPointActivityListByIds(activityIds);
// SPU
spuList.value = [];
spuIdList.value = pointActivityList.value.map(
(activity) => activity.spuId,
);
if (spuIdList.value.length > 0) {
spuList.value = (await ProductSpuApi.getSpuDetailList(
spuIdList.value,
)) as MallPointActivityApi.SpuExtensionWithPoint[];
}
// SPU
pointActivityList.value.forEach((activity) => {
// spuId
const spu = spuList.value.find((spu) => spu.id === activity.spuId);
if (spu) {
spu.pointStock = activity.stock;
spu.pointTotalStock = activity.totalStock;
spu.point = activity.point;
spu.pointPrice = activity.price;
}
});
}
} catch (error) {
console.error('获取积分商城活动细节或 SPU 细节时出错:', error);
}
},
{
immediate: true,
deep: true,
},
);
/**
* 计算商品的间距
* @param index 商品索引
*/
const calculateSpace = (index: number) => {
//
const columns = props.property.layoutType === 'twoCol' ? 2 : 1;
//
const marginLeft = index % columns === 0 ? '0' : `${props.property.space}px`;
//
const marginTop = index < columns ? '0' : `${props.property.space}px`;
return { marginLeft, marginTop };
};
//
const containerRef = ref();
//
const calculateWidth = () => {
let width = '100%';
// - / 2
if (props.property.layoutType === 'twoCol') {
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`;
}
return { width };
};
</script>
<template>
<div
ref="containerRef"
class="min-h-30px box-content flex w-full flex-row flex-wrap"
>
<div
v-for="(spu, index) in spuList"
:key="index"
:style="{
...calculateSpace(index),
...calculateWidth(),
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
>
<!-- 角标 -->
<div
v-if="property.badge.show"
class="z-1 absolute left-0 top-0 items-center justify-center"
>
<el-image
:src="property.badge.imgUrl"
class="h-26px w-38px"
fit="cover"
/>
</div>
<!-- 商品封面图 -->
<div
class="h-140px"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg',
},
]"
>
<el-image :src="spu.picUrl" class="h-full w-full" fit="cover" />
</div>
<div
class="gap-8px p-8px box-border flex flex-col"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]':
property.layoutType === 'oneColSmallImg',
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-14px"
:class="[
{
truncate: property.layoutType !== 'oneColSmallImg',
'line-clamp-2 overflow-ellipsis':
property.layoutType === 'oneColSmallImg',
},
]"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<!-- 商品简介 -->
<div
v-if="property.fields.introduction.show"
:style="{ color: property.fields.introduction.color }"
class="text-12px truncate"
>
{{ spu.introduction }}
</div>
<div>
<!-- 积分 -->
<span
v-if="property.fields.price.show"
:style="{ color: property.fields.price.color }"
class="text-16px"
>
{{ spu.point }}积分
{{
!spu.pointPrice || spu.pointPrice === 0
? ''
: `+${fenToYuan(spu.pointPrice)}`
}}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
:style="{ color: property.fields.marketPrice.color }"
class="ml-4px text-10px line-through"
>
{{ fenToYuan(spu.marketPrice) }}
</span>
</div>
<div class="text-12px">
<!-- 销量 -->
<span
v-if="property.fields.salesCount.show"
:style="{ color: property.fields.salesCount.color }"
>
已兑{{ (spu.pointTotalStock || 0) - (spu.pointStock || 0) }}
</span>
<!-- 库存 -->
<span
v-if="property.fields.stock.show"
:style="{ color: property.fields.stock.color }"
>
库存{{ spu.pointTotalStock || 0 }}
</span>
</div>
</div>
<!-- 购买按钮 -->
<div class="bottom-8px right-8px absolute">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
class="p-x-12px p-y-4px text-12px rounded-full text-white"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
:src="property.btnBuy.imgUrl"
class="h-28px w-28px rounded-full"
fit="cover"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { PromotionPointProperty } from './config';
import { useVModel } from '@vueuse/core';
import PointShowcase from '#/views/mall/promotion/point/components/point-showcase.vue';
//
defineOptions({ name: 'PromotionPointProperty' });
const props = defineProps<{ modelValue: PromotionPointProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form :model="formData" label-width="80px">
<el-card class="property-group" header="积分商城活动" shadow="never">
<PointShowcase v-model="formData.activityIds" />
</el-card>
<el-card class="property-group" header="商品样式" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="单列大图" placement="bottom">
<el-radio-button value="oneColBigImg">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="单列小图" placement="bottom">
<el-radio-button value="oneColSmallImg">
<Icon icon="fluent:text-column-two-left-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button value="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
<!--<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button value="threeCol">
<Icon icon="fluent:text-column-three-24-filled" />
</el-radio-button>
</el-tooltip>-->
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品简介" prop="fields.introduction.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.introduction.color" />
<el-checkbox v-model="formData.fields.introduction.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
<el-form-item label="市场价" prop="fields.marketPrice.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.marketPrice.color" />
<el-checkbox v-model="formData.fields.marketPrice.show" />
</div>
</el-form-item>
<el-form-item label="商品销量" prop="fields.salesCount.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.salesCount.color" />
<el-checkbox v-model="formData.fields.salesCount.show" />
</div>
</el-form-item>
<el-form-item label="商品库存" prop="fields.stock.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.stock.color" />
<el-checkbox v-model="formData.fields.stock.show" />
</div>
</el-form-item>
</el-card>
<el-card class="property-group" header="角标" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item
v-if="formData.badge.show"
label="角标"
prop="badge.imgUrl"
>
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22</template>
</UploadImg>
</el-form-item>
</el-card>
<el-card class="property-group" header="按钮" shadow="never">
<el-form-item label="按钮类型" prop="btnBuy.type">
<el-radio-group v-model="formData.btnBuy.type">
<el-radio-button value="text">文字</el-radio-button>
<el-radio-button value="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="formData.btnBuy.type === 'text'">
<el-form-item label="按钮文字" prop="btnBuy.text">
<el-input v-model="formData.btnBuy.text" />
</el-form-item>
<el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</el-form-item>
<el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="图片" prop="btnBuy.imgUrl">
<UploadImg
v-model="formData.btnBuy.imgUrl"
height="56px"
width="56px"
>
<template #tip> 建议尺寸56 * 56</template>
</UploadImg>
</el-form-item>
</template>
</el-card>
<el-card class="property-group" header="商品样式" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
:show-input-controls="false"
input-size="small"
show-input
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
:show-input-controls="false"
input-size="small"
show-input
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
:show-input-controls="false"
input-size="small"
show-input
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 秒杀属性 */
export interface PromotionSeckillProperty {
// 布局类型:单列 | 三列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol';
// 商品字段
fields: {
// 商品简介
introduction: PromotionSeckillFieldProperty;
// 市场价
marketPrice: PromotionSeckillFieldProperty;
// 商品名称
name: PromotionSeckillFieldProperty;
// 商品价格
price: PromotionSeckillFieldProperty;
// 商品销量
salesCount: PromotionSeckillFieldProperty;
// 商品库存
stock: PromotionSeckillFieldProperty;
};
// 角标
badge: {
// 角标图片
imgUrl: string;
// 是否显示
show: boolean;
};
// 按钮
btnBuy: {
// 文字按钮:背景渐变起始颜色
bgBeginColor: string;
// 文字按钮:背景渐变结束颜色
bgEndColor: string;
// 图片按钮:图片地址
imgUrl: string;
// 文字
text: string;
// 类型:文字 | 图片
type: 'img' | 'text';
};
// 上圆角
borderRadiusTop: number;
// 下圆角
borderRadiusBottom: number;
// 间距
space: number;
// 秒杀活动编号
activityIds: number[];
// 组件样式
style: ComponentStyle;
}
// 商品字段
export interface PromotionSeckillFieldProperty {
// 是否显示
show: boolean;
// 颜色
color: string;
}
// 定义组件
export const component = {
id: 'PromotionSeckill',
name: '秒杀',
icon: 'mdi:calendar-time',
property: {
layoutType: 'oneColBigImg',
fields: {
name: { show: true, color: '#000' },
introduction: { show: true, color: '#999' },
price: { show: true, color: '#ff3000' },
marketPrice: { show: true, color: '#c4c4c4' },
salesCount: { show: true, color: '#c4c4c4' },
stock: { show: false, color: '#c4c4c4' },
},
badge: { show: false, imgUrl: '' },
btnBuy: {
type: 'text',
text: '立即秒杀',
bgBeginColor: '#FF6000',
bgEndColor: '#FE832A',
imgUrl: '',
},
borderRadiusTop: 8,
borderRadiusBottom: 8,
space: 8,
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<PromotionSeckillProperty>;

View File

@ -0,0 +1,227 @@
<script setup lang="ts">
import type { PromotionSeckillProperty } from './config';
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import { ref, watch } from 'vue';
import { fenToYuan } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import * as SeckillActivityApi from '#/api/mall/promotion/seckill/seckillActivity';
/** 秒杀卡片 */
defineOptions({ name: 'PromotionSeckill' });
//
const props = defineProps<{ property: PromotionSeckillProperty }>();
//
const spuList = ref<MallSpuApi.Spu[]>([]);
const spuIdList = ref<number[]>([]);
const seckillActivityList = ref<MallSeckillActivityApi.SeckillActivity[]>([]);
watch(
() => props.property.activityIds,
async () => {
try {
// ID
const activityIds = props.property.activityIds;
// ID
if (Array.isArray(activityIds) && activityIds.length > 0) {
//
seckillActivityList.value =
await SeckillActivityApi.getSeckillActivityListByIds(activityIds);
// SPU
spuList.value = [];
spuIdList.value = seckillActivityList.value
.map((activity) => activity.spuId)
.filter((spuId): spuId is number => typeof spuId === 'number');
if (spuIdList.value.length > 0) {
spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value);
}
// SPU
seckillActivityList.value.forEach((activity) => {
// spuId
const spu = spuList.value.find((spu) => spu.id === activity.spuId);
if (spu) {
// 便
spu.price = Math.min(
activity.seckillPrice || Infinity,
spu.price || Infinity,
);
}
});
}
} catch (error) {
console.error('获取秒杀活动细节或 SPU 细节时出错:', error);
}
},
{
immediate: true,
deep: true,
},
);
/**
* 计算商品的间距
* @param index 商品索引
*/
const calculateSpace = (index: number) => {
//
const columns = props.property.layoutType === 'twoCol' ? 2 : 1;
//
const marginLeft = index % columns === 0 ? '0' : `${props.property.space}px`;
//
const marginTop = index < columns ? '0' : `${props.property.space}px`;
return { marginLeft, marginTop };
};
//
const containerRef = ref();
//
const calculateWidth = () => {
let width = '100%';
// - / 2
if (props.property.layoutType === 'twoCol') {
width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`;
}
return { width };
};
</script>
<template>
<div
class="min-h-30px box-content flex w-full flex-row flex-wrap"
ref="containerRef"
>
<div
class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
:style="{
...calculateSpace(index),
...calculateWidth(),
borderTopLeftRadius: `${property.borderRadiusTop}px`,
borderTopRightRadius: `${property.borderRadiusTop}px`,
borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
borderBottomRightRadius: `${property.borderRadiusBottom}px`,
}"
v-for="(spu, index) in spuList"
:key="index"
>
<!-- 角标 -->
<div
v-if="property.badge.show"
class="z-1 absolute left-0 top-0 items-center justify-center"
>
<el-image
fit="cover"
:src="property.badge.imgUrl"
class="h-26px w-38px"
/>
</div>
<!-- 商品封面图 -->
<div
class="h-140px"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg',
},
]"
>
<el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="gap-8px p-8px box-border flex flex-col"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]':
property.layoutType === 'oneColSmallImg',
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-14px"
:class="[
{
truncate: property.layoutType !== 'oneColSmallImg',
'line-clamp-2 overflow-ellipsis':
property.layoutType === 'oneColSmallImg',
},
]"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<!-- 商品简介 -->
<div
v-if="property.fields.introduction.show"
class="text-12px truncate"
:style="{ color: property.fields.introduction.color }"
>
{{ spu.introduction }}
</div>
<div>
<!-- 价格 -->
<span
v-if="property.fields.price.show"
class="text-16px"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price || Infinity) }}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
class="ml-4px text-10px line-through"
:style="{ color: property.fields.marketPrice.color }"
>
{{ fenToYuan(spu.marketPrice) }}
</span>
</div>
<div class="text-12px">
<!-- 销量 -->
<span
v-if="property.fields.salesCount.show"
:style="{ color: property.fields.salesCount.color }"
>
已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}
</span>
<!-- 库存 -->
<span
v-if="property.fields.stock.show"
:style="{ color: property.fields.stock.color }"
>
库存{{ spu.stock || 0 }}
</span>
</div>
</div>
<!-- 购买按钮 -->
<div class="bottom-8px right-8px absolute">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="p-x-12px p-y-4px text-12px rounded-full text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
class="h-28px w-28px rounded-full"
fit="cover"
:src="property.btnBuy.imgUrl"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import type { PromotionSeckillProperty } from './config';
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import { onMounted, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import * as SeckillActivityApi from '#/api/mall/promotion/seckill/seckillActivity';
import { CommonStatusEnum } from '#/utils/constants';
import SeckillShowcase from '#/views/mall/promotion/seckill/components/seckill-showcase.vue';
//
defineOptions({ name: 'PromotionSeckillProperty' });
const props = defineProps<{ modelValue: PromotionSeckillProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const activityList = ref<MallSeckillActivityApi.SeckillActivity[]>([]);
onMounted(async () => {
const { list } = await SeckillActivityApi.getSeckillActivityPage({
pageNo: 1,
pageSize: 10,
status: CommonStatusEnum.ENABLE,
});
activityList.value = list;
});
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="80px" :model="formData">
<el-card header="秒杀活动" class="property-group" shadow="never">
<SeckillShowcase v-model="formData.activityIds" />
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="布局" prop="type">
<el-radio-group v-model="formData.layoutType">
<el-tooltip class="item" content="单列大图" placement="bottom">
<el-radio-button value="oneColBigImg">
<Icon icon="fluent:text-column-one-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="单列小图" placement="bottom">
<el-radio-button value="oneColSmallImg">
<Icon icon="fluent:text-column-two-left-24-filled" />
</el-radio-button>
</el-tooltip>
<el-tooltip class="item" content="双列" placement="bottom">
<el-radio-button value="twoCol">
<Icon icon="fluent:text-column-two-24-filled" />
</el-radio-button>
</el-tooltip>
<!--<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button value="threeCol">
<Icon icon="fluent:text-column-three-24-filled" />
</el-radio-button>
</el-tooltip>-->
</el-radio-group>
</el-form-item>
<el-form-item label="商品名称" prop="fields.name.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.name.color" />
<el-checkbox v-model="formData.fields.name.show" />
</div>
</el-form-item>
<el-form-item label="商品简介" prop="fields.introduction.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.introduction.color" />
<el-checkbox v-model="formData.fields.introduction.show" />
</div>
</el-form-item>
<el-form-item label="商品价格" prop="fields.price.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.price.color" />
<el-checkbox v-model="formData.fields.price.show" />
</div>
</el-form-item>
<el-form-item label="市场价" prop="fields.marketPrice.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.marketPrice.color" />
<el-checkbox v-model="formData.fields.marketPrice.show" />
</div>
</el-form-item>
<el-form-item label="商品销量" prop="fields.salesCount.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.salesCount.color" />
<el-checkbox v-model="formData.fields.salesCount.show" />
</div>
</el-form-item>
<el-form-item label="商品库存" prop="fields.stock.show">
<div class="gap-8px flex">
<ColorInput v-model="formData.fields.stock.color" />
<el-checkbox v-model="formData.fields.stock.show" />
</div>
</el-form-item>
</el-card>
<el-card header="角标" class="property-group" shadow="never">
<el-form-item label="角标" prop="badge.show">
<el-switch v-model="formData.badge.show" />
</el-form-item>
<el-form-item
label="角标"
prop="badge.imgUrl"
v-if="formData.badge.show"
>
<UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
<template #tip> 建议尺寸36 * 22</template>
</UploadImg>
</el-form-item>
</el-card>
<el-card header="按钮" class="property-group" shadow="never">
<el-form-item label="按钮类型" prop="btnBuy.type">
<el-radio-group v-model="formData.btnBuy.type">
<el-radio-button value="text">文字</el-radio-button>
<el-radio-button value="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="formData.btnBuy.type === 'text'">
<el-form-item label="按钮文字" prop="btnBuy.text">
<el-input v-model="formData.btnBuy.text" />
</el-form-item>
<el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</el-form-item>
<el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="图片" prop="btnBuy.imgUrl">
<UploadImg
v-model="formData.btnBuy.imgUrl"
height="56px"
width="56px"
>
<template #tip> 建议尺寸56 * 56</template>
</UploadImg>
</el-form-item>
</template>
</el-card>
<el-card header="商品样式" class="property-group" shadow="never">
<el-form-item label="上圆角" prop="borderRadiusTop">
<el-slider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="下圆角" prop="borderRadiusBottom">
<el-slider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
<el-form-item label="间隔" prop="space">
<el-slider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,46 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 搜索框属性 */
export interface SearchProperty {
height: number; // 搜索栏高度
showScan: boolean; // 显示扫一扫
borderRadius: number; // 框体样式
placeholder: string; // 占位文字
placeholderPosition: PlaceholderPosition; // 占位文字位置
backgroundColor: string; // 框体颜色
textColor: string; // 字体颜色
hotKeywords: string[]; // 热词
style: ComponentStyle;
}
// 文字位置
export type PlaceholderPosition = 'center' | 'left';
// 定义组件
export const component = {
id: 'SearchBar',
name: '搜索框',
icon: 'ep:search',
property: {
height: 28,
showScan: false,
borderRadius: 0,
placeholder: '搜索商品',
placeholderPosition: 'left',
backgroundColor: 'rgb(238, 238, 238)',
textColor: 'rgb(150, 151, 153)',
hotKeywords: [],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8,
} as ComponentStyle,
},
} as DiyComponent<SearchProperty>;

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { SearchProperty } from './config';
/** 搜索框 */
defineOptions({ name: 'SearchBar' });
defineProps<{ property: SearchProperty }>();
</script>
<template>
<div
class="search-bar"
:style="{
color: property.textColor,
}"
>
<!-- 搜索框 -->
<div
class="inner"
:style="{
height: `${property.height}px`,
background: property.backgroundColor,
borderRadius: `${property.borderRadius}px`,
}"
>
<div
class="placeholder"
:style="{
justifyContent: property.placeholderPosition,
}"
>
<Icon icon="ep:search" />
<span>{{ property.placeholder || '搜索商品' }}</span>
</div>
<div class="right">
<!-- 搜索热词 -->
<span v-for="(keyword, index) in property.hotKeywords" :key="index">{{
keyword
}}</span>
<!-- 扫一扫 -->
<Icon icon="ant-design:scan-outlined" v-show="property.showScan" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.search-bar {
/* 搜索框 */
.inner {
position: relative;
display: flex;
align-items: center;
min-height: 28px;
font-size: 14px;
.placeholder {
display: flex;
gap: 2px;
align-items: center;
width: 100%;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
white-space: nowrap;
}
.right {
position: absolute;
right: 8px;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
}
}
</style>

View File

@ -0,0 +1,100 @@
<script setup lang="ts">
import type { SearchProperty } from './config';
import { watch } from 'vue';
import { isString } from '@vben/utils';
import { useVModel } from '@vueuse/core';
/** 搜索框属性面板 */
defineOptions({ name: 'SearchProperty' });
const props = defineProps<{ modelValue: SearchProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
watch(
() => formData.value.hotKeywords,
(newVal) => {
//
const nonStringIndex = newVal.findIndex((item) => !isString(item));
if (nonStringIndex !== -1) {
formData.value.hotKeywords[nonStringIndex] = '';
}
},
{ deep: true, flush: 'post' },
);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-card header="搜索热词" class="property-group" shadow="never">
<Draggable v-model="formData.hotKeywords" empty-item="" :min="0">
<template #default="{ index }">
<el-input
v-model="formData.hotKeywords[index]"
placeholder="请输入热词"
/>
</template>
</Draggable>
</el-card>
<el-card header="搜索样式" class="property-group" shadow="never">
<el-form-item label="框体样式">
<el-radio-group v-model="formData!.borderRadius">
<el-tooltip content="方形" placement="top">
<el-radio-button :value="0">
<Icon icon="tabler:input-search" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="圆形" placement="top">
<el-radio-button :value="10">
<Icon icon="iconoir:input-search" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="提示文字" prop="placeholder">
<el-input v-model="formData.placeholder" />
</el-form-item>
<el-form-item label="文本位置" prop="placeholderPosition">
<el-radio-group v-model="formData!.placeholderPosition">
<el-tooltip content="居左" placement="top">
<el-radio-button value="left">
<Icon icon="ant-design:align-left-outlined" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="居中" placement="top">
<el-radio-button value="center">
<Icon icon="ant-design:align-center-outlined" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="扫一扫" prop="showScan">
<el-switch v-model="formData!.showScan" />
</el-form-item>
<el-form-item label="框体高度" prop="height">
<el-slider
v-model="formData!.height"
:max="50"
:min="28"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="框体颜色" prop="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</el-form-item>
<el-form-item class="lef" label="文本颜色" prop="textColor">
<ColorInput v-model="formData.textColor" />
</el-form-item>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,172 @@
import type { DiyComponent } from '#/components/diy-editor/util';
/** 底部导航菜单属性 */
export interface TabBarProperty {
// 选项列表
items: TabBarItemProperty[];
// 主题
theme: string;
// 样式
style: TabBarStyle;
}
// 选项属性
export interface TabBarItemProperty {
// 标签文字
text: string;
// 链接
url: string;
// 默认图标链接
iconUrl: string;
// 选中的图标链接
activeIconUrl: string;
}
// 样式
export interface TabBarStyle {
// 背景类型
bgType: 'color' | 'img';
// 背景颜色
bgColor: string;
// 图片链接
bgImg: string;
// 默认颜色
color: string;
// 选中的颜色
activeColor: string;
}
// 定义组件
export const component = {
id: 'TabBar',
name: '底部导航',
icon: 'fluent:table-bottom-row-16-filled',
property: {
theme: 'red',
style: {
bgType: 'color',
bgColor: '#fff',
color: '#282828',
activeColor: '#fc4141',
},
items: [
{
text: '首页',
url: '/pages/index/index',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-002.png',
},
{
text: '分类',
url: '/pages/index/category?id=3',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-002.png',
},
{
text: '购物车',
url: '/pages/index/cart',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-002.png',
},
{
text: '我的',
url: '/pages/index/user',
iconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-001.png',
activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-002.png',
},
],
},
} as DiyComponent<TabBarProperty>;
export const THEME_LIST = [
{
id: 'red',
name: '中国红',
icon: 'icon-park-twotone:theme',
color: '#d10019',
},
{
id: 'orange',
name: '桔橙',
icon: 'icon-park-twotone:theme',
color: '#f37b1d',
},
{
id: 'gold',
name: '明黄',
icon: 'icon-park-twotone:theme',
color: '#fbbd08',
},
{
id: 'green',
name: '橄榄绿',
icon: 'icon-park-twotone:theme',
color: '#8dc63f',
},
{
id: 'cyan',
name: '天青',
icon: 'icon-park-twotone:theme',
color: '#1cbbb4',
},
{
id: 'blue',
name: '海蓝',
icon: 'icon-park-twotone:theme',
color: '#0081ff',
},
{
id: 'purple',
name: '姹紫',
icon: 'icon-park-twotone:theme',
color: '#6739b6',
},
{
id: 'brightRed',
name: '嫣红',
icon: 'icon-park-twotone:theme',
color: '#e54d42',
},
{
id: 'forestGreen',
name: '森绿',
icon: 'icon-park-twotone:theme',
color: '#39b54a',
},
{
id: 'mauve',
name: '木槿',
icon: 'icon-park-twotone:theme',
color: '#9c26b0',
},
{
id: 'pink',
name: '桃粉',
icon: 'icon-park-twotone:theme',
color: '#e03997',
},
{
id: 'brown',
name: '棕褐',
icon: 'icon-park-twotone:theme',
color: '#a5673f',
},
{
id: 'grey',
name: '玄灰',
icon: 'icon-park-twotone:theme',
color: '#8799a3',
},
{
id: 'gray',
name: '草灰',
icon: 'icon-park-twotone:theme',
color: '#aaaaaa',
},
{
id: 'black',
name: '墨黑',
icon: 'icon-park-twotone:theme',
color: '#333333',
},
];

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import type { TabBarProperty } from './config';
/** 页面底部导航栏 */
defineOptions({ name: 'TabBar' });
defineProps<{ property: TabBarProperty }>();
</script>
<template>
<div class="tab-bar">
<div
class="tab-bar-bg"
:style="{
background:
property.style.bgType === 'color'
? property.style.bgColor
: `url(${property.style.bgImg})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
}"
>
<div
v-for="(item, index) in property.items"
:key="index"
class="tab-bar-item"
>
<el-image :src="index === 0 ? item.activeIconUrl : item.iconUrl">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<Icon icon="ep:picture" />
</div>
</template>
</el-image>
<span
:style="{
color:
index === 0 ? property.style.activeColor : property.style.color,
}"
>
{{ item.text }}
</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.tab-bar {
z-index: 2;
width: 100%;
.tab-bar-bg {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
padding: 8px 0;
.tab-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
font-size: 12px;
:deep(img),
.el-icon {
width: 26px;
height: 26px;
border-radius: 4px;
}
}
}
}
</style>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import type { TabBarProperty } from './config';
import { useVModel } from '@vueuse/core';
import { component, THEME_LIST } from './config';
//
defineOptions({ name: 'TabBarProperty' });
const props = defineProps<{ modelValue: TabBarProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
component.property.items = formData.value.items;
//
const handleThemeChange = () => {
const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme);
if (theme?.color) {
formData.value.style.activeColor = theme.color;
}
};
</script>
<template>
<div class="tab-bar">
<!-- 表单 -->
<el-form :model="formData" label-width="80px">
<el-form-item label="主题" prop="theme">
<el-select v-model="formData!.theme" @change="handleThemeChange">
<el-option
v-for="(theme, index) in THEME_LIST"
:key="index"
:label="theme.name"
:value="theme.id"
>
<template #default>
<div class="flex items-center justify-between">
<Icon :icon="theme.icon" :color="theme.color" />
<span>{{ theme.name }}</span>
</div>
</template>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="默认颜色">
<ColorInput v-model="formData!.style.color" />
</el-form-item>
<el-form-item label="选中颜色">
<ColorInput v-model="formData!.style.activeColor" />
</el-form-item>
<el-form-item label="导航背景">
<el-radio-group v-model="formData!.style.bgType">
<el-radio-button value="color">纯色</el-radio-button>
<el-radio-button value="img">图片</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="选择颜色" v-if="formData!.style.bgType === 'color'">
<ColorInput v-model="formData!.style.bgColor" />
</el-form-item>
<el-form-item label="选择图片" v-if="formData!.style.bgType === 'img'">
<UploadImg
v-model="formData!.style.bgImg"
width="100%"
height="50px"
class="min-w-200px"
>
<template #tip> 建议尺寸 375 * 50 </template>
</UploadImg>
</el-form-item>
<el-text tag="p">图标设置</el-text>
<el-text type="info" size="small">
拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44
</el-text>
<Draggable v-model="formData.items" :limit="5">
<template #default="{ element }">
<div class="m-b-8px flex items-center justify-around">
<div class="flex flex-col items-center justify-between">
<UploadImg
v-model="element.iconUrl"
width="40px"
height="40px"
:show-delete="false"
:show-btn-text="false"
/>
<el-text size="small">未选中</el-text>
</div>
<div>
<UploadImg
v-model="element.activeIconUrl"
width="40px"
height="40px"
:show-delete="false"
:show-btn-text="false"
/>
<el-text>已选中</el-text>
</div>
</div>
<el-form-item
prop="text"
label="文字"
label-width="48px"
class="m-b-8px!"
>
<el-input v-model="element.text" placeholder="请输入文字" />
</el-form-item>
<el-form-item
prop="url"
label="链接"
label-width="48px"
class="m-b-0!"
>
<AppLinkInput v-model="element.url" />
</el-form-item>
</template>
</Draggable>
</el-form>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,76 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 标题栏属性 */
export interface TitleBarProperty {
// 背景图
bgImgUrl: string;
// 偏移
marginLeft: number;
// 显示位置
textAlign: 'center' | 'left';
// 主标题
title: string;
// 副标题
description: string;
// 标题大小
titleSize: number;
// 描述大小
descriptionSize: number;
// 标题粗细
titleWeight: number;
// 描述粗细
descriptionWeight: number;
// 标题颜色
titleColor: string;
// 描述颜色
descriptionColor: string;
// 高度
height: number;
// 查看更多
more: {
// 是否显示查看更多
show: false;
// 自定义文字
text: string;
// 样式选择
type: 'all' | 'icon' | 'text';
// 链接
url: string;
};
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'TitleBar',
name: '标题栏',
icon: 'material-symbols:line-start',
property: {
title: '主标题',
description: '副标题',
titleSize: 16,
descriptionSize: 12,
titleWeight: 400,
textAlign: 'left',
descriptionWeight: 200,
titleColor: 'rgba(50, 50, 51, 10)',
descriptionColor: 'rgba(150, 151, 153, 10)',
marginLeft: 0,
height: 40,
more: {
// 查看更多
show: false,
type: 'icon',
text: '查看更多',
url: '',
},
style: {
bgType: 'color',
bgColor: '#fff',
} as ComponentStyle,
},
} as DiyComponent<TitleBarProperty>;

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import type { TitleBarProperty } from './config';
/** 标题栏 */
defineOptions({ name: 'TitleBar' });
defineProps<{ property: TitleBarProperty }>();
</script>
<template>
<div class="title-bar" :style="{ height: `${property.height}px` }">
<el-image
v-if="property.bgImgUrl"
:src="property.bgImgUrl"
fit="cover"
class="w-full"
/>
<div
class="absolute left-0 top-0 flex h-full w-full flex-col justify-center"
>
<!-- 标题 -->
<div
:style="{
fontSize: `${property.titleSize}px`,
fontWeight: property.titleWeight,
color: property.titleColor,
textAlign: property.textAlign,
marginLeft: `${property.marginLeft}px`,
marginBottom: '4px',
}"
v-if="property.title"
>
{{ property.title }}
</div>
<!-- 副标题 -->
<div
:style="{
fontSize: `${property.descriptionSize}px`,
fontWeight: property.descriptionWeight,
color: property.descriptionColor,
textAlign: property.textAlign,
marginLeft: `${property.marginLeft}px`,
}"
v-if="property.description"
>
{{ property.description }}
</div>
</div>
<!-- 更多 -->
<div
class="more"
v-show="property.more.show"
:style="{
color: property.descriptionColor,
}"
>
<span v-if="property.more.type !== 'icon'">
{{ property.more.text }}
</span>
<Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" />
</div>
</div>
</template>
<style scoped lang="scss">
.title-bar {
position: relative;
box-sizing: border-box;
width: 100%;
min-height: 20px;
/* 更多 */
.more {
position: absolute;
top: 0;
right: 8px;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
margin: auto;
font-size: 10px;
color: #969799;
}
}
</style>

View File

@ -0,0 +1,145 @@
<script setup lang="ts">
import type { TitleBarProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'TitleBarProperty' });
const props = defineProps<{ modelValue: TitleBarProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
//
const rules = {};
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<el-form label-width="85px" :model="formData" :rules="rules">
<el-card header="风格" class="property-group" shadow="never">
<el-form-item label="背景图片" prop="bgImgUrl">
<UploadImg v-model="formData.bgImgUrl" width="100%" height="40px">
<template #tip>建议尺寸 750*80</template>
</UploadImg>
</el-form-item>
<el-form-item label="标题位置" prop="textAlign">
<el-radio-group v-model="formData!.textAlign">
<el-tooltip content="居左" placement="top">
<el-radio-button value="left">
<Icon icon="ant-design:align-left-outlined" />
</el-radio-button>
</el-tooltip>
<el-tooltip content="居中" placement="top">
<el-radio-button value="center">
<Icon icon="ant-design:align-center-outlined" />
</el-radio-button>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item label="偏移量" prop="marginLeft" label-width="70px">
<el-slider
v-model="formData.marginLeft"
:max="100"
:min="0"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="高度" prop="height" label-width="70px">
<el-slider
v-model="formData.height"
:max="200"
:min="20"
show-input
input-size="small"
/>
</el-form-item>
</el-card>
<el-card header="主标题" class="property-group" shadow="never">
<el-form-item label="文字" prop="title" label-width="40px">
<InputWithColor
v-model="formData.title"
v-model:color="formData.titleColor"
show-word-limit
maxlength="20"
/>
</el-form-item>
<el-form-item label="大小" prop="titleSize" label-width="40px">
<el-slider
v-model="formData.titleSize"
:max="60"
:min="10"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="粗细" prop="titleWeight" label-width="40px">
<el-slider
v-model="formData.titleWeight"
:min="100"
:max="900"
:step="100"
show-input
input-size="small"
/>
</el-form-item>
</el-card>
<el-card header="副标题" class="property-group" shadow="never">
<el-form-item label="文字" prop="description" label-width="40px">
<InputWithColor
v-model="formData.description"
v-model:color="formData.descriptionColor"
show-word-limit
maxlength="50"
/>
</el-form-item>
<el-form-item label="大小" prop="descriptionSize" label-width="40px">
<el-slider
v-model="formData.descriptionSize"
:max="60"
:min="10"
show-input
input-size="small"
/>
</el-form-item>
<el-form-item label="粗细" prop="descriptionWeight" label-width="40px">
<el-slider
v-model="formData.descriptionWeight"
:min="100"
:max="900"
:step="100"
show-input
input-size="small"
/>
</el-form-item>
</el-card>
<el-card header="查看更多" class="property-group" shadow="never">
<el-form-item label="是否显示" prop="more.show">
<el-checkbox v-model="formData.more.show" />
</el-form-item>
<!-- 更多按钮的 样式选择 -->
<template v-if="formData.more.show">
<el-form-item label="样式" prop="more.type">
<el-radio-group v-model="formData.more.type">
<el-radio value="text">文字</el-radio>
<el-radio value="icon">图标</el-radio>
<el-radio value="all">文字+图标</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="更多文字"
prop="more.text"
v-show="formData.more.type !== 'icon'"
>
<el-input v-model="formData.more.text" />
</el-form-item>
<el-form-item label="跳转链接" prop="more.url">
<AppLinkInput v-model="formData.more.url" />
</el-form-item>
</template>
</el-card>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,24 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 用户卡片属性 */
export interface UserCardProperty {
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'UserCard',
name: '用户卡片',
icon: 'mdi:user-card-details',
property: {
style: {
bgType: 'color',
bgColor: '',
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<UserCardProperty>;

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { UserCardProperty } from './config';
/** 用户卡片 */
defineOptions({ name: 'UserCard' });
//
defineProps<{ property: UserCardProperty }>();
</script>
<template>
<div class="flex flex-col">
<div class="p-x-18px p-y-24px flex items-center justify-between">
<div class="gap-16px flex flex-1 items-center">
<el-avatar :size="60">
<Icon icon="ep:avatar" :size="60" />
</el-avatar>
<span class="text-18px font-bold">芋道源码</span>
</div>
<Icon icon="tdesign:qrcode" :size="20" />
</div>
<div
class="p-x-20px p-y-8px text-12px flex items-center justify-between bg-white"
>
<span class="color-#ff690d">点击绑定手机号</span>
<span class="rounded-26px bg-#ff6100 p-x-8px p-y-5px color-white">
去绑定
</span>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { UserCardProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'UserCardProperty' });
const props = defineProps<{ modelValue: UserCardProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style" />
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,26 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 用户卡券属性 */
export interface UserCouponProperty {
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'UserCoupon',
name: '用户卡券',
icon: 'ep:ticket',
property: {
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<UserCouponProperty>;

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { UserCouponProperty } from './config';
/** 用户卡券 */
defineOptions({ name: 'UserCoupon' });
//
defineProps<{ property: UserCouponProperty }>();
</script>
<template>
<el-image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/couponCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { UserCouponProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'UserCouponProperty' });
const props = defineProps<{ modelValue: UserCouponProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style" />
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,26 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 用户订单属性 */
export interface UserOrderProperty {
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'UserOrder',
name: '用户订单',
icon: 'ep:list',
property: {
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<UserOrderProperty>;

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { UserOrderProperty } from './config';
/** 用户订单 */
defineOptions({ name: 'UserOrder' });
//
defineProps<{ property: UserOrderProperty }>();
</script>
<template>
<el-image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/orderCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { UserOrderProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'UserOrderProperty' });
const props = defineProps<{ modelValue: UserOrderProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style" />
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,26 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 用户资产属性 */
export interface UserWalletProperty {
// 组件样式
style: ComponentStyle;
}
// 定义组件
export const component = {
id: 'UserWallet',
name: '用户资产',
icon: 'ep:wallet-filled',
property: {
style: {
bgType: 'color',
bgColor: '',
marginLeft: 8,
marginRight: 8,
marginBottom: 8,
} as ComponentStyle,
},
} as DiyComponent<UserWalletProperty>;

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { UserWalletProperty } from './config';
/** 用户资产 */
defineOptions({ name: 'UserWallet' });
//
defineProps<{ property: UserWalletProperty }>();
</script>
<template>
<el-image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/walletCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { UserWalletProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'UserWalletProperty' });
const props = defineProps<{ modelValue: UserWalletProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style" />
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,40 @@
import type {
ComponentStyle,
DiyComponent,
} from '#/components/diy-editor/util';
/** 视频播放属性 */
export interface VideoPlayerProperty {
// 视频链接
videoUrl: string;
// 封面链接
posterUrl: string;
// 是否自动播放
autoplay: boolean;
// 组件样式
style: VideoPlayerStyle;
}
// 视频播放样式
export interface VideoPlayerStyle extends ComponentStyle {
// 视频高度
height: number;
}
// 定义组件
export const component = {
id: 'VideoPlayer',
name: '视频播放',
icon: 'ep:video-play',
property: {
videoUrl: '',
posterUrl: '',
autoplay: false,
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8,
height: 300,
} as VideoPlayerStyle,
},
} as DiyComponent<VideoPlayerProperty>;

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import type { VideoPlayerProperty } from './config';
/** 视频播放 */
defineOptions({ name: 'VideoPlayer' });
defineProps<{ property: VideoPlayerProperty }>();
</script>
<template>
<div class="w-full" :style="{ height: `${property.style.height}px` }">
<el-image
class="w-full"
:src="property.posterUrl"
v-if="property.posterUrl"
/>
<video
v-else
class="w-full"
:src="property.videoUrl"
:poster="property.posterUrl"
:autoplay="property.autoplay"
controls
></video>
</div>
</template>
<style scoped lang="scss">
/* 图片 */
img {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { VideoPlayerProperty } from './config';
import { useVModel } from '@vueuse/core';
//
defineOptions({ name: 'VideoPlayerProperty' });
const props = defineProps<{ modelValue: VideoPlayerProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<ComponentContainerProperty v-model="formData.style">
<template #style>
<el-form-item label="高度" prop="height">
<el-slider
v-model="formData.style.height"
:max="500"
:min="100"
show-input
input-size="small"
:show-input-controls="false"
/>
</el-form-item>
</template>
<el-form label-width="80px" :model="formData">
<el-form-item label="上传视频" prop="videoUrl">
<UploadFile
v-model="formData.videoUrl"
:file-type="['mp4']"
:limit="1"
:file-size="100"
class="min-w-80px"
/>
</el-form-item>
<el-form-item label="上传封面" prop="posterUrl">
<UploadImg
v-model="formData.posterUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-80px"
>
<template #tip> 建议宽度750 </template>
</UploadImg>
</el-form-item>
<el-form-item label="自动播放" prop="autoplay">
<el-switch v-model="formData.autoplay" />
</el-form-item>
</el-form>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

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