feat:【antd】【mall】diy-editor 初始化(暂时不可用,保证界面先有。。。)

This commit is contained in:
YunaiV
2025-10-25 23:18:55 +08:00
parent 5e259eb685
commit db1b3be27a
113 changed files with 7955 additions and 609 deletions

View File

@@ -61,6 +61,7 @@
"vue": "catalog:",
"vue-dompurify-html": "catalog:",
"vue-router": "catalog:",
"vue3-signature": "catalog:"
"vue3-signature": "catalog:",
"vuedraggable": "catalog:"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { handleTree } from '@vben/utils';
import * as ProductCategoryApi from '#/api/mall/product/category';
/** 商品分类选择组件 */
defineOptions({ name: 'ProductCategorySelect' });
const props = defineProps({
// 选中的ID
modelValue: {
type: [Number, Array<Number>],
default: undefined,
},
// 是否多选
multiple: {
type: Boolean,
default: false,
},
// 上级品类的编号
parentId: {
type: Number,
default: undefined,
},
});
/** 分类选择 */
const emit = defineEmits(['update:modelValue']);
/** 选中的分类 ID */
const selectCategoryId = computed({
get: () => {
return props.modelValue;
},
set: (val: number | number[]) => {
emit('update:modelValue', val);
},
});
/** 初始化 */
const categoryList = ref<any[]>([]); // 分类树
onMounted(async () => {
// 获得分类树
const data = await ProductCategoryApi.getCategoryList({
parentId: props.parentId,
});
categoryList.value = handleTree(data, 'id', 'parentId');
});
</script>
<template>
<TreeSelect
v-model:value="selectCategoryId"
:tree-data="categoryList"
:field-names="{
children: 'children',
label: 'name',
value: 'id',
}"
:multiple="multiple"
:tree-checkable="multiple"
class="w-1/1"
placeholder="请选择商品分类"
allow-clear
tree-default-expand-all
/>
</template>

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
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); // 选中的分组,默认选中第一个
const activeAppLink = ref({} as AppLink); // 选中的 APP 链接
const linkScrollbar = ref<HTMLDivElement>(); // 右侧滚动条
const groupTitleRefs = ref<HTMLInputElement[]>([]); // 分组标题引用列表
const groupScrollbar = ref<HTMLDivElement>(); // 分组滚动条
const groupBtnRefs = ref<HTMLButtonElement[]>([]); // 分组引用列表
const detailSelectDialog = ref<{
id?: number;
type?: APP_LINK_TYPE_ENUM;
visible: boolean;
}>({
visible: false,
id: undefined,
type: undefined,
}); // 详情选择对话框
/** 打开弹窗 */
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;
}
}
};
function handleSubmit() {
dialogVisible.value = false;
emit('change', activeAppLink.value.path);
emit('appLinkChange', activeAppLink.value);
}
/**
* 处理右侧链接列表滚动
* @param {object} param0 滚动事件参数
* @param {number} param0.scrollTop 滚动条的位置
*/
function 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);
}
}
/** 处理分组选中 */
function handleGroupSelected(group: string) {
activeGroup.value = group;
const titleRef = groupTitleRefs.value.find(
(item: HTMLInputElement) => item.textContent === group,
);
if (titleRef && linkScrollbar.value) {
// 滚动分组标题
linkScrollbar.value.scrollTop = titleRef.offsetTop;
}
}
/** 自动滚动分组按钮,确保分组按钮保持在可视区域内 */
function scrollToGroupBtn(group: string) {
const groupBtn = groupBtnRefs.value
.find((ref: HTMLButtonElement | undefined) => ref?.textContent === group);
if (groupBtn && groupScrollbar.value) {
groupScrollbar.value.scrollTop = groupBtn.offsetTop;
}
}
/** 是否为相同的链接(不比较参数,只比较链接) */
function isSameLink(link1: string, link2: string) {
return link2 ? link1.split('?')[0] === link2.split('?')[0] : false;
}
/** 处理详情选择 */
function handleProductCategorySelected(id: number) {
// TODO @AI这里有点问题activeAppLink 地址;
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>
<Modal v-model:open="dialogVisible" title="选择链接" width="65%">
<div class="flex h-[500px] gap-2">
<!-- 左侧分组列表 -->
<div
class="h-full overflow-y-auto flex flex-col"
ref="groupScrollbar"
>
<Button
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
class="ml-0 mr-4 w-[90px] justify-start mb-1"
:class="[{ active: activeGroup === group.name }]"
ref="groupBtnRefs"
:type="activeGroup === group.name ? 'primary' : 'default'"
@click="handleGroupSelected(group.name)"
>
{{ group.name }}
</Button>
</div>
<!-- 右侧链接列表 -->
<div
class="h-full flex-1 overflow-y-auto"
@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>
<!-- 链接列表 -->
<Tooltip
v-for="(appLink, appLinkIndex) in group.links"
:key="appLinkIndex"
:title="appLink.path"
placement="bottom"
:mouse-enter-delay="0.3"
>
<Button
class="mb-2 ml-0 mr-2"
:type="
isSameLink(appLink.path, activeAppLink.path)
? 'primary'
: 'default'
"
@click="handleAppLinkSelected(appLink)"
>
{{ appLink.name }}
</Button>
</Tooltip>
</div>
</div>
</div>
<!-- 底部对话框操作按钮 -->
<template #footer>
<Button type="primary" @click="handleSubmit"> </Button>
<Button @click="dialogVisible = false"> </Button>
</template>
</Modal>
<Modal v-model:open="detailSelectDialog.visible" title="" width="50%">
<Form class="min-h-[200px]">
<FormItem
label="选择分类"
v-if="
detailSelectDialog.type === APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST
"
>
<ProductCategorySelect
v-model="detailSelectDialog.id"
:parent-id="0"
@update:model-value="handleProductCategorySelected"
/>
</FormItem>
</Form>
</Modal>
</template>
<style lang="scss" scoped>
:deep(.ant-btn + .ant-btn) {
margin-left: 0 !important;
}
</style>

View File

@@ -0,0 +1,220 @@
/** 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,47 @@
<script lang="ts" setup>
// TODO @AI改成 El 标签风格,而不是 el-
// TODO @AI一些 modal 是否使用 Modal 组件,而不是 el-modal
import { ref, watch } from 'vue';
import AppLinkSelectDialog from './app-link-select-dialog.vue';
/** APP 链接输入框 */
defineOptions({ name: 'AppLinkInput' });
// 定义属性
const props = defineProps({
modelValue: {
type: String,
default: '',
}, // 当前选中的链接
});
const emit = defineEmits<{
'update:modelValue': [link: string];
}>();
const dialogRef = ref(); // 选择对话框
const appLink = ref(''); // 当前的链接
const handleOpenDialog = () => dialogRef.value?.open(appLink.value); // 处理打开对话框
const handleLinkSelected = (link: string) => (appLink.value = link); // 处理 APP 链接选中
watch(
() => props.modelValue,
() => (appLink.value = props.modelValue),
{ immediate: true },
);
watch(
() => appLink.value,
() => emit('update:modelValue', appLink.value),
);
</script>
<template>
<InputGroup compact>
<Input v-model:value="appLink" placeholder="输入或选择链接" class="flex-1" />
<Button @click="handleOpenDialog">选择</Button>
</InputGroup>
<AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" />
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
// TODO @AI改成 El 风格而不是iel- 风格;
import { computed } from 'vue';
import { PREDEFINE_COLORS } from '@vben/constants';
/** 颜色输入框 */
defineOptions({ name: 'ColorInput' });
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const color = computed({
get: () => {
return props.modelValue;
},
set: (val: string) => {
emit('update:modelValue', val);
},
});
</script>
<template>
<InputGroup compact>
<ColorPicker v-model:value="color" :presets="PREDEFINE_COLORS" />
<Input v-model:value="color" class="flex-1" />
</InputGroup>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { ComponentStyle } from '../util';
import { useVModel } from '@vueuse/core';
import UploadImg from '#/components/upload/image-upload.vue';
import { ColorInput } from '#/views/mall/promotion/components';
/**
* 组件容器属性:目前右边部分
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
*/
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>
<Tabs>
<!-- 每个组件的自定义内容 -->
<TabPane tab="内容" key="content" v-if="$slots.default">
<slot></slot>
</TabPane>
<!-- 每个组件的通用内容 -->
<TabPane tab="样式" key="style">
<Card title="组件样式" class="property-group">
<Form :model="formData" labelCol="{ span: 6 }" wrapperCol="{ span: 18 }">
<FormItem label="组件背景" name="bgType">
<RadioGroup v-model:value="formData.bgType">
<Radio value="color">纯色</Radio>
<Radio value="img">图片</Radio>
</RadioGroup>
</FormItem>
<FormItem
label="选择颜色"
name="bgColor"
v-if="formData.bgType === 'color'"
>
<ColorInput v-model="formData.bgColor" />
</FormItem>
<FormItem label="上传图片" name="bgImg" v-else>
<UploadImg
v-model="formData.bgImg"
:limit="1"
:show-description="false"
>
<template #tip>建议宽度 750px</template>
</UploadImg>
</FormItem>
<Tree
:tree-data="treeData"
:expand-on-click-node="false"
default-expand-all
>
<template #title="{ data, node }">
<FormItem
:label="data.label"
:name="data.prop"
class="mb-0 w-full"
>
<Slider
v-model:value="
formData[data.prop as keyof ComponentStyle] as number
"
:max="100"
:min="0"
@change="handleSliderChange(data.prop)"
/>
</FormItem>
</template>
</Tree>
<slot name="style" :style="formData"></slot>
</Form>
</Card>
</TabPane>
</Tabs>
</template>
<style scoped lang="scss">
:deep(.ant-slider) {
margin-right: 16px;
}
:deep(.ant-input-number) {
width: 50px;
}
</style>

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import type {
ComponentStyle,
DiyComponent,
} from '../util';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { components } from './mobile';
import { VerticalButtonGroup } from '#/views/mall/promotion/components';
/**
* 组件容器:目前在中间部分
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
*/
defineOptions({ name: 'ComponentContainer', components });
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,
},
showToolbar: {
type: Boolean,
default: true,
},
});
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="showToolbar && component.name && active"
>
<VerticalButtonGroup type="primary">
<Tooltip title="上移" placement="right">
<Button
:disabled="!canMoveUp"
@click.stop="handleMoveComponent(-1)"
>
<IconifyIcon icon="ep:arrow-up" />
</Button>
</Tooltip>
<Tooltip title="下移" placement="right">
<Button
:disabled="!canMoveDown"
@click.stop="handleMoveComponent(1)"
>
<IconifyIcon icon="ep:arrow-down" />
</Button>
</Tooltip>
<Tooltip title="复制" placement="right">
<Button @click.stop="handleCopyComponent()">
<IconifyIcon icon="ep:copy-document" />
</Button>
</Tooltip>
<Tooltip title="删除" placement="right">
<Button @click.stop="handleDeleteComponent()">
<IconifyIcon icon="ep:delete" />
</Button>
</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(--ant-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(--ant-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(--ant-color-primary);
&::after {
border-left-color: var(--ant-color-primary);
}
}
.component-toolbar {
display: block;
}
}
}
}
</style>

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import type {
DiyComponent,
DiyComponentLibrary,
} from '../util';
import { reactive, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import draggable from 'vuedraggable';
import { componentConfigs } from './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>
<div class="editor-left w-[261px]">
<div class="h-full overflow-y-auto">
<Collapse v-model:activeKey="extendGroups">
<CollapsePanel
v-for="group in groups"
:key="group.name"
:header="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">
<IconifyIcon :icon="element.icon" :size="32" />
<span class="mt-1 text-xs">{{ element.name }}</span>
</div>
</div>
</template>
</draggable>
</CollapsePanel>
</Collapse>
</div>
</div>
</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(.ant-collapse) {
border-top: none;
}
:deep(.ant-collapse-item) {
border-bottom: none;
}
:deep(.ant-collapse-content) {
padding-bottom: 0;
}
:deep(.ant-collapse-header) {
height: 32px;
padding: 0 24px !important;
line-height: 32px;
background-color: var(--ant-color-bg-layout);
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(--ant-color-border-secondary);
border-bottom: 1px solid var(--ant-color-border-secondary);
.anticon {
margin-bottom: 4px;
color: gray;
}
}
.component.active,
.component:hover {
color: var(--ant-color-white);
background: var(--ant-color-primary);
.anticon {
color: var(--ant-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 '../../../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,48 @@
<script setup lang="ts">
import type { CarouselProperty } from './config';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
/** 轮播图 */
defineOptions({ name: 'Carousel' });
defineProps<{ property: CarouselProperty }>();
const currentIndex = ref(0);
const handleIndexChange = (index: number) => {
currentIndex.value = index + 1;
};
</script>
<template>
<!-- 无图片 -->
<div
class="flex h-[250px] items-center justify-center bg-gray-300"
v-if="property.items.length === 0"
>
<IconifyIcon icon="tdesign:image" class="text-[120px] text-gray-800" />
</div>
<div v-else class="relative">
<Carousel
:autoplay="property.autoplay"
:autoplaySpeed="property.interval * 1000"
:dots="property.indicator !== 'number'"
@change="handleIndexChange"
class="h-[174px]"
>
<div v-for="(item, index) in property.items" :key="index">
<Image class="h-full w-full object-cover" :src="item.imgUrl" :preview="false" />
</div>
</Carousel>
<div
v-if="property.indicator === 'number'"
class="absolute bottom-[10px] right-[10px] rounded-xl bg-black px-[8px] py-[2px] text-[10px] text-white opacity-40"
>
{{ currentIndex }} / {{ property.items.length }}
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { CarouselProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadFile from '#/components/upload/file-upload.vue';
import UploadImg from '#/components/upload/image-upload.vue';
import { AppLinkInput, Draggable } from '#/views/mall/promotion/components';
// 轮播图属性面板
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">
<ElForm label-width="80px" :model="formData">
<ElCard header="样式设置" class="property-group" shadow="never">
<ElFormItem label="样式" prop="type">
<ElRadioGroup v-model="formData.type">
<ElTooltip class="item" content="默认" placement="bottom">
<ElRadioButton value="default">
<IconifyIcon icon="system-uicons:carousel" />
</ElRadioButton>
</ElTooltip>
<ElTooltip class="item" content="卡片" placement="bottom">
<ElRadioButton value="card">
<IconifyIcon icon="ic:round-view-carousel" />
</ElRadioButton>
</ElTooltip>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="指示器" prop="indicator">
<ElRadioGroup v-model="formData.indicator">
<ElRadio value="dot">小圆点</ElRadio>
<ElRadio value="number">数字</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="是否轮播" prop="autoplay">
<ElSwitch v-model="formData.autoplay" />
</ElFormItem>
<ElFormItem label="播放间隔" prop="interval" v-if="formData.autoplay">
<ElSlider
v-model="formData.interval"
:max="10"
:min="0.5"
:step="0.5"
show-input
input-size="small"
:show-input-controls="false"
/>
<ElText type="info">单位</ElText>
</ElFormItem>
</ElCard>
<ElCard header="内容设置" class="property-group" shadow="never">
<Draggable v-model="formData.items" :empty-item="{ type: 'img' }">
<template #default="{ element }">
<ElFormItem
label="类型"
prop="type"
class="mb-2"
label-width="40px"
>
<ElRadioGroup v-model="element.type">
<ElRadio value="img">图片</ElRadio>
<ElRadio value="video">视频</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
label="图片"
class="mb-2"
label-width="40px"
v-if="element.type === 'img'"
>
<UploadImg
v-model="element.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-[80px]"
:show-description="false"
/>
</ElFormItem>
<template v-else>
<ElFormItem label="封面" class="mb-2" label-width="40px">
<UploadImg
v-model="element.imgUrl"
draggable="false"
:show-description="false"
height="80px"
width="100%"
class="min-w-[80px]"
/>
</ElFormItem>
<ElFormItem label="视频" class="mb-2" label-width="40px">
<UploadFile
v-model="element.videoUrl"
:file-type="['mp4']"
:limit="1"
:file-size="100"
class="min-w-[80px]"
/>
</ElFormItem>
</template>
<ElFormItem label="链接" class="mb-2" label-width="40px">
<AppLinkInput v-model="element.url" />
</ElFormItem>
</template>
</Draggable>
</ElCard>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,29 @@
import type { DiyComponent } from '../../../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,6 @@
<script setup lang="ts">
import type { DividerProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,26 @@
import type { DiyComponent } from '../../../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,43 @@
<script setup lang="ts">
import type { PopoverProperty } from './config';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
/** 弹窗广告 */
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="absolute bottom-1/2 right-1/2 h-[454px] w-[292px] rounded border border-gray-300 bg-white p-0.5"
:style="{
zIndex: 100 + index + (activeIndex === index ? 100 : 0),
marginRight: `${-146 - index * 20}px`,
marginBottom: `${-227 - index * 20}px`,
}"
@click="handleActive(index)"
>
<Image :src="item.imgUrl" fit="contain" class="h-full w-full">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<IconifyIcon icon="ep:picture" />
</div>
</template>
</Image>
<div class="absolute right-1 top-1 text-xs">{{ index + 1 }}</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,4 @@
<script setup lang="ts">
import type { PopoverProperty } from './config';
import { useVModel } from '@vueuse/core';

View File

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

View File

@@ -0,0 +1,50 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,34 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { PromotionDiscountTypeEnum } from '@vben/constants';
import { floatToFixed2 } from '@vben/utils';
// 优惠描述
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,34 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { PromotionDiscountTypeEnum } from '@vben/constants';
import { floatToFixed2 } from '@vben/utils';
// 优惠值
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,28 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { defineComponent } from 'vue';
import { CouponTemplateValidityTypeEnum } from '@vben/constants';
import { formatDate } from '@vben/utils';
// 有效期
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,161 @@
<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>
<div class="z-10 min-h-[30px]" wrap-class="w-full" ref="containerRef">
<div
class="flex flex-row text-xs"
: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="ml-4 flex flex-row justify-between p-2"
>
<div class="flex flex-col justify-evenly gap-1">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<!-- 有效期 -->
<CouponValidTerm :coupon="coupon" />
</div>
<div class="flex flex-col justify-evenly">
<div
class="rounded-full px-2 py-0.5"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
<!-- 布局22-->
<div
v-else-if="property.columns === 2"
class="ml-4 flex flex-row justify-between p-2"
>
<div class="flex flex-col justify-evenly gap-1">
<!-- 优惠值 -->
<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="h-full w-5 rounded-full px-0.5 py-2 text-center"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
<!-- 布局33-->
<div v-else class="flex flex-col items-center justify-around gap-1 p-1">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<div
class="rounded-full px-2 py-0.5"
:style="{
color: property.button.color,
background: property.button.bgColor,
}"
>
立即领取
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { CouponCardProperty } from './config';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { ref, watch } from 'vue';

View File

@@ -0,0 +1,36 @@
import type { DiyComponent } from '../../../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,92 @@
<script setup lang="ts">
import type { FloatingActionButtonProperty } from './config';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { message } from 'ant-design-vue';
/** 悬浮按钮 */
defineOptions({ name: 'FloatingActionButton' });
// 定义属性
defineProps<{ property: FloatingActionButtonProperty }>();
// 是否展开
const expanded = ref(false);
// 处理展开/折叠
const handleToggleFab = () => {
expanded.value = !expanded.value;
};
const handleActive = (index: number) => {
message.success(`点击了${index}`);
};
</script>
<template>
<div
class="absolute bottom-8 right-[calc(50%-375px/2+32px)] z-20 flex items-center gap-3"
: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)"
>
<Image :src="item.imgUrl" fit="contain" class="h-7 w-7">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<IconifyIcon icon="ep:picture" :color="item.textColor" />
</div>
</template>
</Image>
<span
v-if="property.showText"
class="mt-1 text-xs"
:style="{ color: item.textColor }"
>
{{ item.text }}
</span>
</div>
</template>
<!-- todo: @owen 使用APP主题色 -->
<el-button type="primary" size="large" circle @click="handleToggleFab">
<IconifyIcon
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,4 @@
<script setup lang="ts">
import type { FloatingActionButtonProperty } from './config';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,175 @@
import type { StyleValue } from 'vue';
import type { HotZoneItemProperty } from '../../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
/**
* 封装热区拖拽
*
* 注为什么不使用vueuse的useDraggable。在本场景下其使用方式比较复杂
* @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,11 @@
<script setup lang="ts">
import type { ControlDot } from './controller';
import type { HotZoneItemProperty } from '../../config';
import type { AppLink } from '#/views/mall/promotion/components/app-link-input/data';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';

View File

@@ -0,0 +1,46 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,46 @@
<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">
<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,80 @@
<script setup lang="ts">
import type { HotZoneProperty } from './config';
import { ref } from 'vue';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';
import HotZoneEditDialog from './components/hot-zone-edit-dialog/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">
<!-- 表单 -->
<ElForm label-width="80px" :model="formData" class="mt-2">
<ElFormItem label="上传图片" prop="imgUrl">
<UploadImg
v-model="formData.imgUrl"
height="50px"
width="auto"
class="min-w-[80px]"
:show-description="false"
>
<template #tip>
<ElText type="info" size="small"> 推荐宽度 750</ElText>
</template>
</UploadImg>
</ElFormItem>
</ElForm>
<ElButton type="primary" plain class="w-full" @click="handleOpenEditDialog">
设置热区
</ElButton>
</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 '../../../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,30 @@
<script setup lang="ts">
import type { ImageBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 图片展示 */
defineOptions({ name: 'ImageBar' });
defineProps<{ property: ImageBarProperty }>();
</script>
<template>
<!-- 无图片 -->
<div
class="flex h-12 items-center justify-center bg-gray-300"
v-if="!property.imgUrl"
>
<IconifyIcon icon="ep:picture" class="text-3xl text-gray-600" />
</div>
<Image class="min-h-8 w-full" v-else :src="property.imgUrl" :preview="false" />
</template>
<style scoped lang="scss">
/* 图片 */
img {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ImageBarProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';
import { AppLinkInput } from '#/views/mall/promotion/components';
// 图片展示属性面板
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">
<ElForm label-width="80px" :model="formData">
<ElFormItem label="上传图片" prop="imgUrl">
<UploadImg
v-model="formData.imgUrl"
draggable="false"
height="80px"
width="100%"
class="min-w-[80px]"
:show-description="false"
>
<template #tip> 建议宽度750 </template>
</UploadImg>
</ElFormItem>
<ElFormItem label="链接" prop="url">
<AppLinkInput v-model="formData.url" />
</ElFormItem>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,69 @@
import { defineAsyncComponent } from 'vue';
/*
* 组件注册
*
* 组件规范:
* 1. 每个子目录就是一个独立的组件,每个目录包括以下三个文件:
* 2. config.ts组件配置必选用于定义组件、组件默认的属性、定义属性的类型
* 3. index.vue组件展示用于展示组件的渲染效果。可以不提供如 Page页面设置只需要属性配置表单即可
* 4. property.vue组件属性表单用于配置组件必选
*
* 注:
* 组件ID以config.ts中配置的id为准与组件目录的名称无关但还是建议组件目录的名称与组件ID保持一致
*/
// 导入组件界面模块
const viewModules: Record<string, any> = import.meta.glob('./*/*.vue');
// 导入配置模块
const configModules: Record<string, any> = import.meta.glob('./*/config.ts', {
eager: true,
});
// 界面模块
const components: Record<string, any> = {};
// 组件配置模块
const componentConfigs: Record<string, any> = {};
// 组件界面的类型
type ViewType = 'index' | 'property';
/**
* 注册组件的界面模块
*
* @param componentId 组件ID
* @param configPath 配置模块的文件路径
* @param viewType 组件界面的类型
*/
const registerComponentViewModule = (
componentId: string,
configPath: string,
viewType: ViewType,
) => {
const viewPath = configPath.replace('config.ts', `${viewType}.vue`);
const viewModule = viewModules[viewPath];
if (viewModule) {
// 定义异步组件
components[componentId] = defineAsyncComponent(viewModule);
}
};
// 注册
Object.keys(configModules).forEach((modulePath: string) => {
const component = configModules[modulePath].component;
const componentId = component?.id;
if (componentId) {
// 注册组件
componentConfigs[componentId] = component;
// 注册预览界面
registerComponentViewModule(componentId, modulePath, 'index');
// 注册属性配置表单
registerComponentViewModule(
`${componentId}Property`,
modulePath,
'property',
);
}
});
export { componentConfigs, components };

View File

@@ -0,0 +1,56 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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;
// 右
right: number;
// 下
bottom: 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,83 @@
<script setup lang="ts">
import type { MagicCubeProperty } from './config';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
/** 广告魔方 */
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`,
}"
>
<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`,
}"
>
<IconifyIcon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
</div>
</div>
</template>
</Image>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import type { MagicCubeProperty } from './config';
import { ref } from 'vue';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';

View File

@@ -0,0 +1,83 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,46 @@
<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="relative flex flex-col items-center pb-3.5 pt-5"
:style="{ width: `${100 * (1 / property.column)}%` }"
>
<!-- 右上角角标 -->
<span
v-if="item.badge?.show"
class="absolute left-1/2 top-2.5 z-10 h-5 rounded-full px-1.5 text-center text-xs leading-5"
:style="{
color: item.badge.textColor,
backgroundColor: item.badge.bgColor,
}"
>
{{ item.badge.text }}
</span>
<Image v-if="item.iconUrl" class="h-7 w-7" :src="item.iconUrl" :preview="false" />
<span
class="mt-2 h-4 text-xs leading-4"
:style="{ color: item.titleColor }"
>
{{ item.title }}
</span>
<span
class="mt-1.5 h-3 text-xs leading-3"
:style="{ color: item.subtitleColor }"
>
{{ item.subtitle }}
</span>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { MenuGridProperty } from './config';
import { useVModel } from '@vueuse/core';
import {
ElCard,
ElForm,
ElFormItem,
ElRadio,
ElRadioGroup,
ElSwitch,
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';
import { AppLinkInput, Draggable } from '#/views/mall/promotion/components';
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">
<!-- 表单 -->
<ElForm label-width="80px" :model="formData" class="mt-2">
<ElFormItem label="每行数量" prop="column">
<ElRadioGroup v-model="formData.column">
<ElRadio :value="3">3</ElRadio>
<ElRadio :value="4">4</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElCard header="菜单设置" class="property-group" shadow="never">
<Draggable
v-model="formData.list"
:empty-item="EMPTY_MENU_GRID_ITEM_PROPERTY"
>
<template #default="{ element }">
<ElFormItem label="图标" prop="iconUrl">
<UploadImg
v-model="element.iconUrl"
height="80px"
width="80px"
:show-description="false"
>
<template #tip> 建议尺寸44 * 44 </template>
</UploadImg>
</ElFormItem>
<ElFormItem label="标题" prop="title">
<InputWithColor
v-model="element.title"
v-model:color="element.titleColor"
/>
</ElFormItem>
<ElFormItem label="副标题" prop="subtitle">
<InputWithColor
v-model="element.subtitle"
v-model:color="element.subtitleColor"
/>
</ElFormItem>
<ElFormItem label="链接" prop="url">
<AppLinkInput v-model="element.url" />
</ElFormItem>
<ElFormItem label="显示角标" prop="badge.show">
<ElSwitch v-model="element.badge.show" />
</ElFormItem>
<template v-if="element.badge.show">
<ElFormItem label="角标内容" prop="badge.text">
<InputWithColor
v-model="element.badge.text"
v-model:color="element.badge.textColor"
/>
</ElFormItem>
<ElFormItem label="背景颜色" prop="badge.bgColor">
<ColorInput v-model="element.badge.bgColor" />
</ElFormItem>
</template>
</template>
</Draggable>
</ElCard>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,52 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,39 @@
<script setup lang="ts">
import type { MenuListProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 列表导航 */
defineOptions({ name: 'MenuList' });
defineProps<{ property: MenuListProperty }>();
</script>
<template>
<div class="flex min-h-[42px] flex-col">
<div
v-for="(item, index) in property.list"
:key="index"
class="item flex h-[42px] flex-row items-center justify-between gap-1 px-3"
>
<div class="flex flex-1 flex-row items-center gap-2">
<Image v-if="item.iconUrl" class="h-4 w-4" :src="item.iconUrl" />
<span class="text-base" :style="{ color: item.titleColor }">{{
item.title
}}</span>
</div>
<div class="item-center flex flex-row justify-center gap-1">
<span class="text-xs" :style="{ color: item.subtitleColor }">{{
item.subtitle
}}</span>
<IconifyIcon 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,7 @@
<script setup lang="ts">
import type { MenuListProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';

View File

@@ -0,0 +1,70 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,136 @@
<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>
<Carousel
:autoplay="false"
arrows
dots
:style="{ height: `${carouselHeight}px` }"
>
<div 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="absolute -right-2.5 -top-2.5 z-10 h-5 rounded-[10px] px-1.5 text-center text-xs leading-5"
:style="{
color: item.badge.textColor,
backgroundColor: item.badge.bgColor,
}"
>
{{ item.badge.text }}
</span>
<Image
v-if="item.iconUrl"
:src="item.iconUrl"
class="h-full w-full"
:preview="false"
/>
</div>
<!-- 标题 -->
<span
v-if="property.layout === 'iconText'"
class="text-xs"
:style="{
color: item.titleColor,
height: `${TITLE_HEIGHT}px`,
lineHeight: `${TITLE_HEIGHT}px`,
}"
>
{{ item.title }}
</span>
</div>
</div>
</div>
</Carousel>
</template>
<style lang="scss">
// Ant Design Vue Carousel 样式调整
:deep(.ant-carousel .ant-carousel-dots) {
.ant-carousel-dot {
width: 6px;
height: 6px;
border-radius: 6px;
button {
width: 6px;
height: 6px;
border-radius: 6px;
background: #ff6000;
}
}
.ant-carousel-dot-active button {
width: 12px;
background: #ff6000;
}
}
</style>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { MenuSwiperProperty } from './config';
import { cloneDeep } from '@vben/utils';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,8 @@
<script lang="ts" setup>
import type { NavigationBarCellProperty } from '../config';
import type { Rect } from '#/views/mall/promotion/components/magic-cube-editor/util';
import { computed, ref } from 'vue';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,82 @@
import type { DiyComponent } from '../../../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,112 @@
<script setup lang="ts">
import type { StyleValue } from 'vue';
import type {
NavigationBarCellProperty,
NavigationBarProperty,
} from './config';
import type { SearchProperty } from '../search-bar/config';
import { computed } from 'vue';
import appNavbarMp from '#/assets/imgs/diy/app-nav-bar-mp.png';
import SearchBar from '../search-bar/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="appNavbarMp"
alt=""
style="width: 86px; height: 30px"
/>
</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,117 @@
<script setup lang="ts">
import type { NavigationBarProperty } from './config';
import { useVModel } from '@vueuse/core';
import NavigationBarCellProperty from './components/cell-property.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"
:show-description="false"
/>
<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="mb-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="mb-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 '../../../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,37 @@
<script setup lang="ts">
import type { NoticeBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 公告栏 */
defineOptions({ name: 'NoticeBar' });
defineProps<{ property: NoticeBarProperty }>();
</script>
<template>
<div
class="flex items-center py-1 text-xs"
:style="{
backgroundColor: property.backgroundColor,
color: property.textColor,
}"
>
<ElImage :src="property.iconUrl" class="h-[18px]" />
<ElDivider direction="vertical" />
<ElCarousel
height="24px"
direction="vertical"
:autoplay="true"
class="flex-1 pr-2"
>
<ElCarouselItem v-for="(item, index) in property.contents" :key="index">
<div class="h-6 truncate leading-6">{{ item.text }}</div>
</ElCarouselItem>
</ElCarousel>
<IconifyIcon icon="ep:arrow-right" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { NoticeBarProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';

View File

@@ -0,0 +1,23 @@
import type { DiyComponent } from '../../../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,45 @@
<script setup lang="ts">
import type { PageConfigProperty } from './config';
import { useVModel } from '@vueuse/core';
import UploadImg from '#/components/upload/image-upload.vue';
import { ColorInput } from '#/views/mall/promotion/components';
// 导航栏属性面板
defineOptions({ name: 'PageConfigProperty' });
const props = defineProps<{ modelValue: PageConfigProperty }>();
const emit = defineEmits(['update:modelValue']);
// 表单校验
const rules = {};
const formData = useVModel(props, 'modelValue', emit);
</script>
<template>
<Form :model="formData" :rules="rules" labelCol="{ span: 6 }" wrapperCol="{ span: 18 }">
<FormItem label="页面描述" name="description">
<Textarea
v-model:value="formData!.description"
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
:rows="3"
/>
</FormItem>
<FormItem label="背景颜色" name="backgroundColor">
<ColorInput v-model="formData!.backgroundColor" />
</FormItem>
<FormItem label="背景图片" name="backgroundImage">
<UploadImg
v-model="formData!.backgroundImage"
:limit="1"
:show-description="false"
>
<template #tip>建议宽度 750px</template>
</UploadImg>
</FormItem>
</Form>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,100 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,189 @@
<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="box-content flex min-h-[30px] 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="absolute left-0 top-0 z-[1] items-center justify-center"
>
<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',
},
]"
>
<Image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="box-border flex flex-col gap-[8px] p-[8px]"
: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="truncate text-[12px]"
: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="absolute bottom-[8px] right-[8px]">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="rounded-full px-[12px] py-[4px] text-[12px] text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<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,6 @@
<script setup lang="ts">
import type { ProductCardProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,67 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,149 @@
<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>
<div class="z-10 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="absolute left-0 top-0 z-10 items-center justify-center"
>
<Image
fit="cover"
:src="property.badge.imgUrl"
class="h-[26px] w-[38px]"
/>
</div>
<!-- 商品封面图 -->
<Image
fit="cover"
:src="spu.picUrl"
:style="{ width: imageSize, height: imageSize }"
/>
<div
class="box-border flex flex-col gap-2 p-2"
:class="[
{
'w-[calc(100%-64px)]': columns === 2,
'w-full': columns === 3,
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="truncate text-xs"
:style="{ color: property.fields.name.color }"
>
{{ spu.name }}
</div>
<div>
<!-- 商品价格 -->
<span
v-if="property.fields.price.show"
class="text-xs"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price || 0) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ProductListProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
import UploadImg from '#/components/upload/image-upload.vue';
import { InputWithColor as ColorInput } from '#/views/mall/promotion/components';
// TODO: 添加组件
// 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">
<ElForm label-width="80px" :model="formData">
<ElCard header="商品列表" class="property-group" shadow="never">
<!-- <SpuShowcase v-model="formData.spuIds" /> -->
</ElCard>
<ElCard header="商品样式" class="property-group" shadow="never">
<ElFormItem label="布局" prop="type">
<ElRadioGroup v-model="formData.layoutType">
<ElTooltip class="item" content="双列" placement="bottom">
<ElRadioButton value="twoCol">
<IconifyIcon icon="fluent:text-column-two-24-filled" />
</ElRadioButton>
</ElTooltip>
<ElTooltip class="item" content="三列" placement="bottom">
<ElRadioButton value="threeCol">
<IconifyIcon icon="fluent:text-column-three-24-filled" />
</ElRadioButton>
</ElTooltip>
<ElTooltip class="item" content="水平滑动" placement="bottom">
<ElRadioButton value="horizSwiper">
<IconifyIcon icon="system-uicons:carousel" />
</ElRadioButton>
</ElTooltip>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="商品名称" prop="fields.name.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.name.color" />
<ElCheckbox v-model="formData.fields.name.show" />
</div>
</ElFormItem>
<ElFormItem label="商品价格" prop="fields.price.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.price.color" />
<ElCheckbox v-model="formData.fields.price.show" />
</div>
</ElFormItem>
</ElCard>
<ElCard header="角标" class="property-group" shadow="never">
<ElFormItem label="角标" prop="badge.show">
<ElSwitch v-model="formData.badge.show" />
</ElFormItem>
<ElFormItem label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
<UploadImg
v-model="formData.badge.imgUrl"
height="44px"
width="72px"
:show-description="false"
>
<template #tip> 建议尺寸36 * 22 </template>
</UploadImg>
</ElFormItem>
</ElCard>
<ElCard header="商品样式" class="property-group" shadow="never">
<ElFormItem label="上圆角" prop="borderRadiusTop">
<ElSlider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
<ElFormItem label="下圆角" prop="borderRadiusBottom">
<ElSlider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
<ElFormItem label="间隔" prop="space">
<ElSlider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
</ElCard>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,28 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,67 @@
<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';
import ComponentContainerProperty from '../../component-container-property.vue';
// 营销文章属性面板
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">
<ElForm label-width="40px" :model="formData">
<ElFormItem label="文章" prop="id">
<ElSelect
v-model="formData.id"
placeholder="请选择文章"
class="w-full"
filterable
remote
:remote-method="queryArticleList"
:loading="loading"
>
<ElOption
v-for="article in articles"
:key="article.id"
:label="article.title"
:value="article.id"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,232 @@
<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="box-content flex min-h-[30px] 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="absolute left-0 top-0 z-[1] items-center justify-center"
>
<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',
},
]"
>
<Image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="box-border flex flex-col gap-2 p-2"
: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="truncate text-[12px]"
: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="absolute bottom-[8px] right-[8px]">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="rounded-full px-[12px] py-[4px] text-[12px] text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<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,181 @@
<script setup lang="ts">
import type { PromotionCombinationProperty } from './config';
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import { onMounted, ref } from 'vue';
import { CommonStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import * as CombinationActivityApi from '#/api/mall/promotion/combination/combinationActivity';
import UploadImg from '#/components/upload/image-upload.vue';
import CombinationShowcase from '#/views/mall/promotion/combination/components/combination-showcase.vue';
import { ColorInput } from '#/views/mall/promotion/components';
// 拼团属性面板
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">
<ElForm label-width="80px" :model="formData">
<ElCard header="拼团活动" class="property-group" shadow="never">
<CombinationShowcase v-model="formData.activityIds" />
</ElCard>
<ElCard header="商品样式" class="property-group" shadow="never">
<ElFormItem label="布局" prop="type">
<ElRadioGroup v-model="formData.layoutType">
<ElTooltip class="item" content="单列大图" placement="bottom">
<ElRadioButton value="oneColBigImg">
<IconifyIcon icon="fluent:text-column-one-24-filled" />
</ElRadioButton>
</ElTooltip>
<ElTooltip class="item" content="单列小图" placement="bottom">
<ElRadioButton value="oneColSmallImg">
<IconifyIcon icon="fluent:text-column-two-left-24-filled" />
</ElRadioButton>
</ElTooltip>
<ElTooltip class="item" content="双列" placement="bottom">
<ElRadioButton value="twoCol">
<IconifyIcon icon="fluent:text-column-two-24-filled" />
</ElRadioButton>
</ElTooltip>
<!--<el-tooltip class="item" content="三列" placement="bottom">
<el-radio-button value="threeCol">
<IconifyIcon icon="fluent:text-column-three-24-filled" />
</ElRadioButton>
</ElTooltip>-->
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="商品名称" prop="fields.name.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.name.color" />
<ElCheckbox v-model="formData.fields.name.show" />
</div>
</ElFormItem>
<ElFormItem label="商品简介" prop="fields.introduction.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.introduction.color" />
<ElCheckbox v-model="formData.fields.introduction.show" />
</div>
</ElFormItem>
<ElFormItem label="商品价格" prop="fields.price.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.price.color" />
<ElCheckbox v-model="formData.fields.price.show" />
</div>
</ElFormItem>
<ElFormItem label="市场价" prop="fields.marketPrice.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.marketPrice.color" />
<ElCheckbox v-model="formData.fields.marketPrice.show" />
</div>
</ElFormItem>
<ElFormItem label="商品销量" prop="fields.salesCount.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.salesCount.color" />
<ElCheckbox v-model="formData.fields.salesCount.show" />
</div>
</ElFormItem>
<ElFormItem label="商品库存" prop="fields.stock.show">
<div class="flex gap-2">
<ColorInput v-model="formData.fields.stock.color" />
<ElCheckbox v-model="formData.fields.stock.show" />
</div>
</ElFormItem>
</ElCard>
<ElCard header="角标" class="property-group" shadow="never">
<ElFormItem label="角标" prop="badge.show">
<ElSwitch v-model="formData.badge.show" />
</ElFormItem>
<ElFormItem 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>
</ElFormItem>
</ElCard>
<ElCard header="按钮" class="property-group" shadow="never">
<ElFormItem label="按钮类型" prop="btnBuy.type">
<ElRadioGroup v-model="formData.btnBuy.type">
<ElRadioButton value="text">文字</ElRadioButton>
<ElRadioButton value="img">图片</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<template v-if="formData.btnBuy.type === 'text'">
<ElFormItem label="按钮文字" prop="btnBuy.text">
<ElInput v-model="formData.btnBuy.text" />
</ElFormItem>
<ElFormItem label="左侧背景" prop="btnBuy.bgBeginColor">
<ColorInput v-model="formData.btnBuy.bgBeginColor" />
</ElFormItem>
<ElFormItem label="右侧背景" prop="btnBuy.bgEndColor">
<ColorInput v-model="formData.btnBuy.bgEndColor" />
</ElFormItem>
</template>
<template v-else>
<ElFormItem label="图片" prop="btnBuy.imgUrl">
<UploadImg
v-model="formData.btnBuy.imgUrl"
height="56px"
width="56px"
:show-description="false"
>
<template #tip> 建议尺寸56 * 56</template>
</UploadImg>
</ElFormItem>
</template>
</ElCard>
<ElCard header="商品样式" class="property-group" shadow="never">
<ElFormItem label="上圆角" prop="borderRadiusTop">
<ElSlider
v-model="formData.borderRadiusTop"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
<ElFormItem label="下圆角" prop="borderRadiusBottom">
<ElSlider
v-model="formData.borderRadiusBottom"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
<ElFormItem label="间隔" prop="space">
<ElSlider
v-model="formData.space"
:max="100"
:min="0"
show-input
input-size="small"
:show-input-controls="false"
/>
</ElFormItem>
</ElCard>
</ElForm>
</ComponentContainerProperty>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,233 @@
<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="box-content flex min-h-[30px] 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="absolute left-0 top-0 z-[1] items-center justify-center"
>
<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',
},
]"
>
<Image :src="spu.picUrl" class="h-full w-full" fit="cover" />
</div>
<div
class="box-border flex flex-col gap-2 p-2"
: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="truncate text-[12px]"
>
{{ 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="absolute bottom-[8px] right-[8px]">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
class="rounded-full px-[12px] py-[4px] text-[12px] text-white"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<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,6 @@
<script lang="ts" setup>
import type { PromotionPointProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,99 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,228 @@
<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="box-content flex min-h-[30px] 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="absolute left-0 top-0 z-[1] items-center justify-center"
>
<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',
},
]"
>
<Image fit="cover" class="h-full w-full" :src="spu.picUrl" />
</div>
<div
class="box-border flex flex-col gap-2 p-2"
:class="[
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-[calc(100%-140px-16px)]':
property.layoutType === 'oneColSmallImg',
},
]"
>
<!-- 商品名称 -->
<div
v-if="property.fields.name.show"
class="text-sm"
: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="truncate text-xs"
:style="{ color: property.fields.introduction.color }"
>
{{ spu.introduction }}
</div>
<div>
<!-- 价格 -->
<span
v-if="property.fields.price.show"
class="text-base"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price || Infinity) }}
</span>
<!-- 市场价 -->
<span
v-if="property.fields.marketPrice.show && spu.marketPrice"
class="ml-1 text-[10px] line-through"
:style="{ color: property.fields.marketPrice.color }"
>
{{ fenToYuan(spu.marketPrice) }}
</span>
</div>
<div class="text-xs">
<!-- 销量 -->
<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="absolute bottom-2 right-2">
<!-- 文字按钮 -->
<span
v-if="property.btnBuy.type === 'text'"
class="rounded-full px-3 py-1 text-xs text-white"
:style="{
background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`,
}"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<Image
v-else
class="h-7 w-7 rounded-full"
fit="cover"
:src="property.btnBuy.imgUrl"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type { PromotionSeckillProperty } from './config';
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import { onMounted, ref } from 'vue';
import { CommonStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,46 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,83 @@
<script setup lang="ts">
import type { SearchProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 搜索框 */
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,
}"
>
<IconifyIcon icon="ep:search" />
<span>{{ property.placeholder || '搜索商品' }}</span>
</div>
<div class="right">
<!-- 搜索热词 -->
<span v-for="(keyword, index) in property.hotKeywords" :key="index">{{
keyword
}}</span>
<!-- 扫一扫 -->
<IconifyIcon
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,9 @@
<script setup lang="ts">
import type { SearchProperty } from './config';
import { watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isString } from '@vben/utils';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,172 @@
import type { DiyComponent } from '../../../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,79 @@
<script setup lang="ts">
import type { TabBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 页面底部导航栏 */
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"
>
<Image :src="index === 0 ? item.activeIconUrl : item.iconUrl">
<template #error>
<div class="flex h-full w-full items-center justify-center">
<IconifyIcon icon="ep:picture" />
</div>
</template>
</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,121 @@
<script setup lang="ts">
import type { TabBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import UploadImg from '#/components/upload/image-upload.vue';
import {
AppLinkInput,
ColorInput,
Draggable,
} from '#/views/mall/promotion/components';
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">
<!-- 表单 -->
<Form :model="formData" labelCol="{ span: 6 }" wrapperCol="{ span: 18 }">
<FormItem label="主题" name="theme">
<Select v-model:value="formData!.theme" @change="handleThemeChange">
<SelectOption
v-for="(theme, index) in THEME_LIST"
:key="index"
:label="theme.name"
:value="theme.id"
>
<div class="flex items-center justify-between">
<IconifyIcon :icon="theme.icon" :color="theme.color" />
<span>{{ theme.name }}</span>
</div>
</SelectOption>
</Select>
</FormItem>
<FormItem label="默认颜色">
<ColorInput v-model="formData!.style.color" />
</FormItem>
<FormItem label="选中颜色">
<ColorInput v-model="formData!.style.activeColor" />
</FormItem>
<FormItem label="导航背景">
<RadioGroup v-model:value="formData!.style.bgType">
<RadioButton value="color">纯色</RadioButton>
<RadioButton value="img">图片</RadioButton>
</RadioGroup>
</FormItem>
<FormItem label="选择颜色" v-if="formData!.style.bgType === 'color'">
<ColorInput v-model="formData!.style.bgColor" />
</FormItem>
<FormItem label="选择图片" v-if="formData!.style.bgType === 'img'">
<UploadImg
v-model="formData!.style.bgImg"
width="100%"
height="50px"
class="min-w-[200px]"
:show-description="false"
>
<template #tip> 建议尺寸 375 * 50 </template>
</UploadImg>
</FormItem>
<div class="text-base mb-2">图标设置</div>
<div class="text-xs text-gray-500 mb-2">
拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44
</div>
<Draggable v-model="formData.items" :limit="5">
<template #default="{ element }">
<div class="mb-2 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-description="false"
/>
<div class="text-xs">未选中</div>
</div>
<div>
<UploadImg
v-model="element.activeIconUrl"
width="40px"
height="40px"
:show-delete="false"
:show-description="false"
/>
<div class="text-xs">已选中</div>
</div>
</div>
<FormItem name="text" label="文字" labelCol="{ span: 4 }" wrapperCol="{ span: 20 }" class="mb-2">
<Input v-model:value="element.text" placeholder="请输入文字" />
</FormItem>
<FormItem name="url" label="链接" labelCol="{ span: 4 }" wrapperCol="{ span: 20 }" class="mb-0">
<AppLinkInput v-model="element.url" />
</FormItem>
</template>
</Draggable>
</Form>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,76 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,87 @@
<script setup lang="ts">
import type { TitleBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 标题栏 */
defineOptions({ name: 'TitleBar' });
defineProps<{ property: TitleBarProperty }>();
</script>
<template>
<div class="title-bar" :style="{ height: `${property.height}px` }">
<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>
<IconifyIcon 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,6 @@
<script setup lang="ts">
import type { TitleBarProperty } from './config';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';

View File

@@ -0,0 +1,24 @@
import type {
ComponentStyle,
DiyComponent,
} from '../../../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,34 @@
<script setup lang="ts">
import type { UserCardProperty } from './config';
import { IconifyIcon } from '@vben/icons';
/** 用户卡片 */
defineOptions({ name: 'UserCard' });
// 定义属性
defineProps<{ property: UserCardProperty }>();
</script>
<template>
<div class="flex flex-col">
<div class="flex items-center justify-between px-[18px] py-[24px]">
<div class="flex flex-1 items-center gap-[16px]">
<Avatar :size="60">
<IconifyIcon icon="ep:avatar" :size="60" />
</Avatar>
<span class="text-[18px] font-bold">芋道源码</span>
</div>
<IconifyIcon icon="tdesign:qrcode" :size="20" />
</div>
<div
class="flex items-center justify-between bg-white px-[20px] py-[8px] text-[12px]"
>
<span class="text-[#ff690d]">点击绑定手机号</span>
<span class="rounded-[26px] bg-[#ff6100] px-[8px] py-[5px] text-white">
去绑定
</span>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { UserCardProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
// 用户卡片属性面板
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 '../../../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,16 @@
<script setup lang="ts">
import type { UserCouponProperty } from './config';
/** 用户卡券 */
defineOptions({ name: 'UserCoupon' });
// 定义属性
defineProps<{ property: UserCouponProperty }>();
</script>
<template>
<Image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/couponCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { UserCouponProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
// 用户卡券属性面板
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 '../../../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,16 @@
<script setup lang="ts">
import type { UserOrderProperty } from './config';
/** 用户订单 */
defineOptions({ name: 'UserOrder' });
// 定义属性
defineProps<{ property: UserOrderProperty }>();
</script>
<template>
<Image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/orderCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { UserOrderProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
// 用户订单属性面板
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 '../../../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,16 @@
<script setup lang="ts">
import type { UserWalletProperty } from './config';
/** 用户资产 */
defineOptions({ name: 'UserWallet' });
// 定义属性
defineProps<{ property: UserWalletProperty }>();
</script>
<template>
<Image
src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/walletCardStyle.png"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { UserWalletProperty } from './config';
import { useVModel } from '@vueuse/core';
import ComponentContainerProperty from '../../component-container-property.vue';
// 用户资产属性面板
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 '../../../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>;

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