营销:适配商城装修组件【优惠券】

This commit is contained in:
owen 2023-11-22 16:58:00 +08:00
parent 1bb9df7b3c
commit 253401ace3
14 changed files with 436 additions and 56 deletions

View File

@ -73,6 +73,13 @@ export function getCouponTemplatePage(params: PageParam) {
}) })
} }
// 获得优惠劵模板分页
export function getCouponTemplateList(ids: number[]) {
return request.get({
url: `/promotion/coupon-template/list?ids=${ids}`
})
}
// 导出优惠劵模板 Excel // 导出优惠劵模板 Excel
export function exportCouponTemplateExcel(params: PageParam) { export function exportCouponTemplateExcel(params: PageParam) {
return request.get({ return request.get({

View File

@ -0,0 +1,78 @@
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
import { CouponTemplateValidityTypeEnum, PromotionDiscountTypeEnum } from '@/utils/constants'
import { floatToFixed2 } from '@/utils'
import { formatDate } from '@/utils/formatTime'
// 优惠值
export const CouponDiscount = defineComponent({
name: 'CouponDiscount',
props: {
coupon: {
type: CouponTemplateApi.CouponTemplateVO
}
},
setup(props) {
const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
// 折扣
let value = coupon.discountPercent + ''
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>
)
}
})
// 优惠描述
export const CouponDiscountDesc = defineComponent({
name: 'CouponDiscountDesc',
props: {
coupon: {
type: CouponTemplateApi.CouponTemplateVO
}
},
setup(props) {
const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
// 使用条件
const useCondition = coupon.usePrice > 0 ? `${floatToFixed2(coupon.usePrice)}元,` : ''
// 优惠描述
const discountDesc =
coupon.discountType === PromotionDiscountTypeEnum.PRICE.type
? `${floatToFixed2(coupon.discountPrice)}`
: `${coupon.discountPercent}`
return () => (
<div>
<span>{useCondition}</span>
<span>{discountDesc}</span>
</div>
)
}
})
// 有效期
export const CouponValidTerm = defineComponent({
name: 'CouponValidTerm',
props: {
coupon: {
type: CouponTemplateApi.CouponTemplateVO
}
},
setup(props) {
const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
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,47 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 商品卡片属性 */
export interface CouponCardProperty {
// 列数
columns: number
// 背景图
bgImg: string
// 文字颜色
textColor: string
// 按钮样式
button: {
// 颜色
color: string
// 背景颜色
bgColor: 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,142 @@
<template>
<el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef">
<div
class="flex flex-row text-12px"
:style="{
gap: `${property.space}px`,
width: scrollbarWidth
}"
>
<div
class="box-content"
:style="{
background: property.bgImg
? `url(${property.bgImg}) 100% center / 100% 100% no-repeat`
: '#fff',
width: `${couponWidth}px`,
color: property.textColor
}"
v-for="(coupon, index) in couponList"
:key="index"
>
<!-- 布局11-->
<div v-if="property.columns === 1" class="m-l-16px flex flex-row justify-between p-8px">
<div class="flex flex-col justify-evenly gap-4px">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<!-- 优惠描述 -->
<CouponDiscountDesc :coupon="coupon" />
<!-- 有效期 -->
<CouponValidTerm :coupon="coupon" />
</div>
<div class="flex flex-col justify-evenly">
<div
class="rounded-20px p-x-8px p-y-2px"
:style="{
color: property.button.color,
background: property.button.bgColor
}"
>
立即领取
</div>
</div>
</div>
<!-- 布局22-->
<div
v-else-if="property.columns === 2"
class="m-l-16px flex flex-row justify-between p-8px"
>
<div class="flex flex-col justify-evenly gap-4px">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<div>{{ coupon.name }}</div>
</div>
<div class="flex flex-col">
<div
class="h-full w-20px rounded-20px p-x-2px p-y-8px 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-4px p-4px">
<!-- 优惠值 -->
<CouponDiscount :coupon="coupon" />
<div>{{ coupon.name }}</div>
<div
class="rounded-20px p-x-8px p-y-2px"
:style="{
color: property.button.color,
background: property.button.bgColor
}"
>
立即领取
</div>
</div>
</div>
</div>
</el-scrollbar>
</template>
<script setup lang="ts">
import { CouponCardProperty } from './config'
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
import { CouponDiscount } from './component'
import {
CouponDiscountDesc,
CouponValidTerm
} from '@/components/DiyEditor/components/mobile/CouponCard/component'
/** 商品卡片 */
defineOptions({ name: 'CouponCard' })
//
const props = defineProps<{ property: CouponCardProperty }>()
//
const couponList = ref<CouponTemplateApi.CouponTemplateVO[]>([])
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 * 0.95 - 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>
<style scoped lang="scss"></style>

View File

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

View File

@ -62,7 +62,7 @@ export interface ProductCardFieldProperty {
export const component = { export const component = {
id: 'ProductCard', id: 'ProductCard',
name: '商品卡片', name: '商品卡片',
icon: 'system-uicons:carousel', icon: 'fluent:text-column-two-left-24-filled',
property: { property: {
layoutType: 'oneColBigImg', layoutType: 'oneColBigImg',
fields: { fields: {

View File

@ -41,7 +41,7 @@ export interface ProductListFieldProperty {
export const component = { export const component = {
id: 'ProductList', id: 'ProductList',
name: '商品栏', name: '商品栏',
icon: 'system-uicons:carousel', icon: 'fluent:text-column-two-24-filled',
property: { property: {
layoutType: 'twoCol', layoutType: 'twoCol',
fields: { fields: {

View File

@ -6,7 +6,7 @@
:style="{ :style="{
gridGap: `${property.space}px`, gridGap: `${property.space}px`,
gridTemplateColumns, gridTemplateColumns,
width: scrollbarWidth, width: scrollbarWidth
}" }"
> >
<!-- 商品 --> <!-- 商品 -->
@ -63,11 +63,11 @@
</el-scrollbar> </el-scrollbar>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ProductListProperty } from "./config" import { ProductListProperty } from './config'
import * as ProductSpuApi from "@/api/mall/product/spu" import * as ProductSpuApi from '@/api/mall/product/spu'
/** 商品卡片 */ /** 商品卡片 */
defineOptions({ name: "ProductList" }) defineOptions({ name: 'ProductList' })
// //
const props = defineProps<{ property: ProductListProperty }>() const props = defineProps<{ property: ProductListProperty }>()
// //
@ -89,39 +89,42 @@ const containerRef = ref()
// //
const columns = ref(2) const columns = ref(2)
// //
const scrollbarWidth = ref("100%") const scrollbarWidth = ref('100%')
// //
const imageSize = ref("0") const imageSize = ref('0')
// //
const gridTemplateColumns = ref("") const gridTemplateColumns = ref('')
// //
watch( watch(
() => [props.property, phoneWidth, spuList.value.length], () => [props.property, phoneWidth, spuList.value.length],
() => { () => {
// //
columns.value = props.property.layoutType === "twoCol" ? 2 : 3 columns.value = props.property.layoutType === 'twoCol' ? 2 : 3
//
// - * ( - 1)/ // - * ( - 1)/
const productWidth = (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value const productWidth =
(phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value
// 2 3 // 2 3
imageSize.value = columns.value === 2 ? "64px" : `${productWidth}px` imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px`
// //
if (props.property.layoutType === "horizSwiper") { if (props.property.layoutType === 'horizSwiper') {
// //
gridTemplateColumns.value = `repeat(auto-fill, ${productWidth}px)` gridTemplateColumns.value = `repeat(auto-fill, ${productWidth}px)`
// //
scrollbarWidth.value = `${productWidth * spuList.value.length + props.property.space * (spuList.value.length - 1)}px` scrollbarWidth.value = `${
productWidth * spuList.value.length + props.property.space * (spuList.value.length - 1)
}px`
} else { } else {
// //
gridTemplateColumns.value = `repeat(${columns.value}, auto)` gridTemplateColumns.value = `repeat(${columns.value}, auto)`
// //
scrollbarWidth.value = "100%" scrollbarWidth.value = '100%'
} }
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
) )
onMounted(() => { onMounted(() => {
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375; //
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375
}) })
</script> </script>

View File

@ -111,7 +111,11 @@ export const PAGE_LIBS = [
{ {
name: '会员组件', name: '会员组件',
extended: true, extended: true,
components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard'] components: ['UserCard', 'UserOrder', 'UserWallet', 'UserCoupon']
}, },
{ name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] } {
name: '营销组件',
extended: true,
components: ['CombinationCard', 'SeckillCard', 'PointCard', 'CouponCard']
}
] as DiyComponentLibrary[] ] as DiyComponentLibrary[]

View File

@ -48,7 +48,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as CommentApi from '@/api/mall/product/comment' import * as CommentApi from '@/api/mall/product/comment'
import SpuShowcase from "@/views/mall/product/spu/components/SpuShowcase.vue"; import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
import * as ProductSpuApi from '@/api/mall/product/spu' import * as ProductSpuApi from '@/api/mall/product/spu'
import SkuTableSelect from '@/views/mall/product/spu/components/SkuTableSelect.vue' import SkuTableSelect from '@/views/mall/product/spu/components/SkuTableSelect.vue'

View File

@ -27,7 +27,7 @@ import * as ProductSpuApi from '@/api/mall/product/spu'
import SpuTableSelect from '@/views/mall/product/spu/components/SpuTableSelect.vue' import SpuTableSelect from '@/views/mall/product/spu/components/SpuTableSelect.vue'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
import { oneOfType } from 'vue-types' import { oneOfType } from 'vue-types'
import { isArray } from "@/utils/is"; import { isArray } from '@/utils/is'
// 使 // 使
// //
@ -43,9 +43,9 @@ const props = defineProps({
// //
const canAdd = computed(() => { const canAdd = computed(() => {
// //
if(props.disabled) return false if (props.disabled) return false
// //
if(!props.limit) return true if (!props.limit) return true
// //
return productSpus.value.length < props.limit return productSpus.value.length < props.limit
}) })
@ -57,20 +57,19 @@ watch(
() => props.modelValue, () => props.modelValue,
async () => { async () => {
const ids = isArray(props.modelValue) const ids = isArray(props.modelValue)
// ? //
? props.modelValue props.modelValue
// : //
: props.modelValue ? [props.modelValue]: [] props.modelValue
? [props.modelValue]
: []
// //
if(ids.length === 0) { if (ids.length === 0) {
productSpus.value = [] productSpus.value = []
return return
} }
// //
if ( if (productSpus.value.length === 0 || productSpus.value.some((spu) => !ids.includes(spu.id!))) {
productSpus.value.length === 0 ||
productSpus.value.some((spu) => !ids.includes(spu.id!))
) {
productSpus.value = await ProductSpuApi.getSpuDetailList(ids) productSpus.value = await ProductSpuApi.getSpuDetailList(ids)
} }
}, },
@ -103,12 +102,15 @@ const handleRemoveSpu = (index: number) => {
} }
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
const emitSpuChange = () => { const emitSpuChange = () => {
if(props.limit === 1) { if (props.limit === 1) {
const spu = productSpus.value.length > 0 ? productSpus.value[0] : null const spu = productSpus.value.length > 0 ? productSpus.value[0] : null
emit('update:modelValue', spu?.id || 0) emit('update:modelValue', spu?.id || 0)
emit('change', spu) emit('change', spu)
} else { } else {
emit('update:modelValue', productSpus.value.map((spu) => spu.id)) emit(
'update:modelValue',
productSpus.value.map((spu) => spu.id)
)
emit('change', productSpus.value) emit('change', productSpus.value)
} }
} }

View File

@ -167,7 +167,7 @@ const open = (spuList?: Spu[]) => {
// //
if (spuList && spuList.length > 0) { if (spuList && spuList.length > 0) {
checkedSpus.value = [...spuList] checkedSpus.value = [...spuList]
checkedStatus.value = Object.fromEntries(spuList.map(spu => [spu.id, true])) checkedStatus.value = Object.fromEntries(spuList.map((spu) => [spu.id, true]))
} }
dialogVisible.value = true dialogVisible.value = true
@ -184,7 +184,9 @@ const getList = async () => {
list.value = data.list list.value = data.list
total.value = data.total total.value = data.total
// checkboxundefinedbool // checkboxundefinedbool
list.value.forEach( spu => checkedStatus.value[spu.id] = checkedStatus.value[spu.id] || false) list.value.forEach(
(spu) => (checkedStatus.value[spu.id] = checkedStatus.value[spu.id] || false)
)
// //
calculateIsCheckAll() calculateIsCheckAll()
} finally { } finally {
@ -272,23 +274,19 @@ const handleCheckOne = (checked: boolean, spu: Spu, isCalcCheckAll: boolean) =>
} }
// //
if(isCalcCheckAll){ if (isCalcCheckAll) {
calculateIsCheckAll() calculateIsCheckAll()
} }
} }
// //
const findCheckedIndex = (spu: Spu) => checkedSpus.value.findIndex(item => item.id === spu.id) const findCheckedIndex = (spu: Spu) => checkedSpus.value.findIndex((item) => item.id === spu.id)
// //
const calculateIsCheckAll = () => { const calculateIsCheckAll = () => {
isCheckAll.value = list.value.every(spu => { isCheckAll.value = list.value.every((spu) => checkedStatus.value[spu.id])
let valueElement = checkedStatus.value[spu.id];
debugger
return valueElement;
});
// && // &&
isIndeterminate.value = !isCheckAll.value && list.value.some(spu => checkedStatus.value[spu.id]) isIndeterminate.value = !isCheckAll.value && list.value.some((spu) => checkedStatus.value[spu.id])
} }
// //

View File

@ -150,15 +150,14 @@ import {
} from '@/views/mall/promotion/coupon/formatter' } from '@/views/mall/promotion/coupon/formatter'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
import type { GiveCouponTemplate } from '@/api/mall/product/spu'
defineOptions({ name: 'CouponSelect' }) defineOptions({ name: 'CouponSelect' })
defineProps<{ defineProps<{
multipleSelection: GiveCouponTemplate[] multipleSelection: CouponTemplateApi.CouponTemplateVO[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:multipleSelection', v: GiveCouponTemplate[]) (e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[])
}>() }>()
const dialogVisible = ref(false) // const dialogVisible = ref(false) //
const dialogTitle = ref('选择优惠卷') // const dialogTitle = ref('选择优惠卷') //
@ -210,10 +209,7 @@ const open = async () => {
defineExpose({ open }) // open defineExpose({ open }) // open
const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => { const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => {
emit( emit('update:multipleSelection', val)
'update:multipleSelection',
val.map((item) => ({ id: item.id, name: item.name }))
)
} }
const submitForm = () => { const submitForm = () => {

View File

@ -187,7 +187,7 @@ import {
PromotionDiscountTypeEnum, PromotionDiscountTypeEnum,
PromotionProductScopeEnum PromotionProductScopeEnum
} from '@/utils/constants' } from '@/utils/constants'
import SpuShowcase from "@/views/mall/product/spu/components/SpuShowcase.vue"; import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue' import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue'
import { convertToInteger, formatToFraction } from '@/utils' import { convertToInteger, formatToFraction } from '@/utils'
@ -385,5 +385,4 @@ function setProductScopeValues(data: CouponTemplateApi.CouponTemplateVO) {
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>