diff --git a/src/components/DiyEditor/components/mobile/MagicCube/config.ts b/src/components/DiyEditor/components/mobile/MagicCube/config.ts index ce5d21a3..bd3120bb 100644 --- a/src/components/DiyEditor/components/mobile/MagicCube/config.ts +++ b/src/components/DiyEditor/components/mobile/MagicCube/config.ts @@ -33,7 +33,7 @@ export interface MagicCubeItemProperty { export const component = { id: 'MagicCube', name: '广告魔方', - icon: 'fluent:puzzle-cube-piece-20-filled', + icon: 'bi:columns', property: { borderRadiusTop: 0, borderRadiusBottom: 0, diff --git a/src/components/DiyEditor/components/mobile/ProductCard/config.ts b/src/components/DiyEditor/components/mobile/ProductCard/config.ts new file mode 100644 index 00000000..49dd30d0 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/config.ts @@ -0,0 +1,97 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 商品卡片属性 */ +export interface ProductCardProperty { + // 布局类型:单列大图 | 单列小图 | 双列 + layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol' + // 商品字段 + fields: { + // 商品名称 + name: ProductCardFieldProperty + // 商品简介 + introduction: ProductCardFieldProperty + // 商品价格 + price: ProductCardFieldProperty + // 商品市场价 + marketPrice: ProductCardFieldProperty + // 商品销量 + salesCount: ProductCardFieldProperty + // 商品库存 + stock: ProductCardFieldProperty + } + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标图片 + imgUrl: string + } + // 按钮 + btnBuy: { + // 类型:文字 | 图片 + type: 'text' | 'img' + // 文字 + text: string + // 文字按钮:背景渐变起始颜色 + bgBeginColor: string + // 文字按钮:背景渐变结束颜色 + bgEndColor: string + // 图片按钮:图片地址 + imgUrl: string + } + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间距 + space: number + // 商品编号列表 + spuIds: number[] + // 组件样式 + style: ComponentStyle +} +// 商品字段 +export interface ProductCardFieldProperty { + // 是否显示 + show: boolean + // 颜色 + color: string +} + +// 定义组件 +export const component = { + id: 'ProductCard', + name: '商品卡片', + icon: 'system-uicons:carousel', + 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: 8, + borderRadiusBottom: 8, + space: 8, + spuIds: [], + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<ProductCardProperty> diff --git a/src/components/DiyEditor/components/mobile/ProductCard/index.vue b/src/components/DiyEditor/components/mobile/ProductCard/index.vue new file mode 100644 index 00000000..a6894ed9 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/index.vue @@ -0,0 +1,165 @@ +<template> + <div :class="`box-content min-h-30px w-full flex 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"> + <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" /> + </div> + <!-- 商品封面图 --> + <div + :class="[ + 'h-140px', + { + 'w-full': property.layoutType !== 'oneColSmallImg', + 'w-140px': property.layoutType === 'oneColSmallImg' + } + ]" + > + <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" /> + </div> + <div + :class="[ + ' flex flex-col gap-8px p-8px box-border', + { + 'w-full': property.layoutType !== 'oneColSmallImg', + 'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg' + } + ]" + > + <!-- 商品名称 --> + <div + v-if="property.fields.name.show" + :class="[ + 'text-14px ', + { + truncate: property.layoutType !== 'oneColSmallImg', + 'overflow-ellipsis line-clamp-2': 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 }" + > + ¥{{ spu.price }} + </span> + <!-- 市场价 --> + <span + v-if="property.fields.marketPrice.show && spu.marketPrice" + class="ml-4px text-10px line-through" + :style="{ color: property.fields.marketPrice.color }" + >¥{{ 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 p-x-12px p-y-4px text-12px text-white" + :style="{ + background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}` + }" + > + {{ property.btnBuy.text }} + </span> + <!-- 图片按钮 --> + <el-image + v-else + class="h-28px w-28px rounded-full" + fit="cover" + :src="property.btnBuy.imgUrl" + /> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +import { ProductCardProperty } from './config' +import * as ProductSpuApi from '@/api/mall/product/spu' + +/** 商品卡片 */ +defineOptions({ name: 'ProductCard' }) +// 定义属性 +const props = defineProps<{ property: ProductCardProperty }>() +// 商品列表 +const spuList = ref<ProductSpuApi.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> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/ProductCard/property.vue b/src/components/DiyEditor/components/mobile/ProductCard/property.vue new file mode 100644 index 00000000..cfa5008b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/property.vue @@ -0,0 +1,149 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="商品列表" class="property-group" shadow="never"> + <SpuShowcase v-model="formData.spuIds" /> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="布局" prop="type"> + <el-radio-group v-model="formData.layoutType"> + <el-tooltip class="item" content="单列大图" placement="bottom"> + <el-radio-button label="oneColBigImg"> + <Icon icon="fluent:text-column-one-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="单列小图" placement="bottom"> + <el-radio-button label="oneColSmallImg"> + <Icon icon="fluent:text-column-two-left-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="双列" placement="bottom"> + <el-radio-button label="twoCol"> + <Icon icon="fluent:text-column-two-24-filled" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="商品名称" prop="fields.name.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.name.color" /> + <el-checkbox v-model="formData.fields.name.show" /> + </div> + </el-form-item> + <el-form-item label="商品简介" prop="fields.introduction.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.introduction.color" /> + <el-checkbox v-model="formData.fields.introduction.show" /> + </div> + </el-form-item> + <el-form-item label="商品价格" prop="fields.price.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.price.color" /> + <el-checkbox v-model="formData.fields.price.show" /> + </div> + </el-form-item> + <el-form-item label="市场价" prop="fields.marketPrice.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.marketPrice.color" /> + <el-checkbox v-model="formData.fields.marketPrice.show" /> + </div> + </el-form-item> + <el-form-item label="商品销量" prop="fields.salesCount.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.salesCount.color" /> + <el-checkbox v-model="formData.fields.salesCount.show" /> + </div> + </el-form-item> + <el-form-item label="商品库存" prop="fields.stock.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.stock.color" /> + <el-checkbox v-model="formData.fields.stock.show" /> + </div> + </el-form-item> + </el-card> + <el-card header="角标" class="property-group" shadow="never"> + <el-form-item label="角标" prop="badge.show"> + <el-switch v-model="formData.badge.show" /> + </el-form-item> + <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show"> + <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px"> + <template #tip> 建议尺寸:36 * 22 </template> + </UploadImg> + </el-form-item> + </el-card> + <el-card header="按钮" class="property-group" shadow="never"> + <el-form-item label="按钮类型" prop="btnBuy.type"> + <el-radio-group v-model="formData.btnBuy.type"> + <el-radio-button label="text">文字</el-radio-button> + <el-radio-button label="img">图片</el-radio-button> + </el-radio-group> + </el-form-item> + <template v-if="formData.btnBuy.type === 'text'"> + <el-form-item label="按钮文字" prop="btnBuy.text"> + <el-input v-model="formData.btnBuy.text" /> + </el-form-item> + <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor"> + <ColorInput v-model="formData.btnBuy.bgBeginColor" /> + </el-form-item> + <el-form-item label="右侧背景" prop="btnBuy.bgEndColor"> + <ColorInput v-model="formData.btnBuy.bgEndColor" /> + </el-form-item> + </template> + <template v-else> + <el-form-item label="图片" prop="btnBuy.imgUrl"> + <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px"> + <template #tip> 建议尺寸:56 * 56 </template> + </UploadImg> + </el-form-item> + </template> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { ProductCardProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue' + +// 商品卡片属性面板 +defineOptions({ name: 'ProductCardProperty' }) + +const props = defineProps<{ modelValue: ProductCardProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/views/mall/product/spu/components/SpuShowcase.vue b/src/views/mall/product/spu/components/SpuShowcase.vue new file mode 100644 index 00000000..a3f16fba --- /dev/null +++ b/src/views/mall/product/spu/components/SpuShowcase.vue @@ -0,0 +1,126 @@ +<template> + <div class="flex flex-wrap items-center gap-8px"> + <div v-for="(spu, index) in productSpus" :key="spu.id" class="select-box spu-pic"> + <el-tooltip :content="spu.name"> + <div class="relative h-full w-full"> + <el-image :src="spu.picUrl" class="h-full w-full" /> + <Icon + v-show="!disabled" + class="del-icon" + icon="ep:circle-close-filled" + @click="handleRemoveSpu(index)" + /> + </div> + </el-tooltip> + </div> + <el-tooltip content="选择商品"> + <div + v-show="!disabled" + v-if="!limit || limit <= productSpus.length" + class="select-box" + @click="openSpuTableSelect" + > + <Icon icon="ep:plus" /> + </div> + </el-tooltip> + </div> + <!-- 商品选择对话框(表格形式) --> + <SpuTableSelect ref="spuTableSelectRef" multiple @change="handleSpuSelected" /> +</template> +<script lang="ts" setup> +import * as ProductSpuApi from '@/api/mall/product/spu' +import SpuTableSelect from '@/views/mall/product/spu/components/SpuTableSelect.vue' +import { propTypes } from '@/utils/propTypes' +import { array } from 'vue-types' + +// 商品橱窗,一般用于与商品建立关系时使用 +// 提供功能:展示商品列表、添加商品、移除商品 +defineOptions({ name: 'SpuShowcase' }) + +const props = defineProps({ + modelValue: array<number>().def([]).isRequired, + // 限制数量:默认不限制 + limit: propTypes.number.def(0), + disabled: propTypes.bool.def(false) +}) + +// 商品列表 +const productSpus = ref<ProductSpuApi.Spu[]>([]) + +watch( + () => props.modelValue, + async () => { + if (props.modelValue.length === 0) { + productSpus.value = [] + return + } + // 只有商品发生变化之后,才去查询商品 + if ( + productSpus.value.length === 0 || + productSpus.value.some((spu) => !props.modelValue.includes(spu.id)) + ) { + debugger + productSpus.value = await ProductSpuApi.getSpuDetailList(props.modelValue) + } + }, + { immediate: true } +) + +/** 商品表格选择对话框 */ +const spuTableSelectRef = ref() +// 打开对话框 +const openSpuTableSelect = () => { + spuTableSelectRef.value.open(productSpus.value) +} + +/** + * 选择商品后触发 + * @param spus 选中的商品列表 + */ +const handleSpuSelected = (spus: ProductSpuApi.Spu[]) => { + productSpus.value = spus + emitSpuChange() +} + +/** + * 删除商品 + * @param index 商品索引 + */ +const handleRemoveSpu = (index: number) => { + productSpus.value.splice(index, 1) + emitSpuChange() +} +const emit = defineEmits(['update:modelValue', 'change']) +const emitSpuChange = () => { + emit( + 'update:modelValue', + productSpus.value.map((spu) => spu.id) + ) + emit('change', productSpus.value) +} +</script> + +<style lang="scss" scoped> +.select-box { + display: flex; + width: 60px; + height: 60px; + border: 1px dashed var(--el-border-color-darker); + border-radius: 8px; + align-items: center; + justify-content: center; +} + +.spu-pic { + position: relative; +} + +.del-icon { + position: absolute; + top: -10px; + right: -10px; + z-index: 1; + width: 20px !important; + height: 20px !important; +} +</style>