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

(cherry picked from commit 253401ace3)
This commit is contained in:
owen 2023-11-22 16:58:00 +08:00 committed by shizhong
parent 6b43ec9d3c
commit a494d2723b
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
export function exportCouponTemplateExcel(params: PageParam) {
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 = {
id: 'ProductCard',
name: '商品卡片',
icon: 'system-uicons:carousel',
icon: 'fluent:text-column-two-left-24-filled',
property: {
layoutType: 'oneColBigImg',
fields: {

View File

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

View File

@ -1,13 +1,13 @@
<template>
<el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef">
<!-- 商品网格 -->
<!-- 商品网格 -->
<div
class="grid overflow-x-auto"
:style="{
gridGap: `${property.space}px`,
gridTemplateColumns,
width: scrollbarWidth,
}"
width: scrollbarWidth
}"
>
<!-- 商品 -->
<div
@ -63,11 +63,11 @@
</el-scrollbar>
</template>
<script setup lang="ts">
import { ProductListProperty } from "./config"
import * as ProductSpuApi from "@/api/mall/product/spu"
import { ProductListProperty } from './config'
import * as ProductSpuApi from '@/api/mall/product/spu'
/** 商品卡片 */
defineOptions({ name: "ProductList" })
defineOptions({ name: 'ProductList' })
//
const props = defineProps<{ property: ProductListProperty }>()
//
@ -89,39 +89,42 @@ const containerRef = ref()
//
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(
() => [props.property, phoneWidth, spuList.value.length],
() => {
//
columns.value = props.property.layoutType === "twoCol" ? 2 : 3
//
columns.value = props.property.layoutType === 'twoCol' ? 2 : 3
// - * ( - 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
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)`
//
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 {
//
gridTemplateColumns.value = `repeat(${columns.value}, auto)`
//
scrollbarWidth.value = "100%"
scrollbarWidth.value = '100%'
}
},
{ immediate: true, deep: true }
)
onMounted(() => {
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375;
//
phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375
})
</script>

View File

@ -111,7 +111,11 @@ export const PAGE_LIBS = [
{
name: '会员组件',
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[]

View File

@ -48,7 +48,7 @@
</template>
<script lang="ts" setup>
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 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 { propTypes } from '@/utils/propTypes'
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(() => {
//
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
})
@ -57,20 +57,19 @@ watch(
() => props.modelValue,
async () => {
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 = []
return
}
//
if (
productSpus.value.length === 0 ||
productSpus.value.some((spu) => !ids.includes(spu.id!))
) {
if (productSpus.value.length === 0 || productSpus.value.some((spu) => !ids.includes(spu.id!))) {
productSpus.value = await ProductSpuApi.getSpuDetailList(ids)
}
},
@ -103,12 +102,15 @@ const handleRemoveSpu = (index: number) => {
}
const emit = defineEmits(['update:modelValue', 'change'])
const emitSpuChange = () => {
if(props.limit === 1) {
if (props.limit === 1) {
const spu = productSpus.value.length > 0 ? productSpus.value[0] : null
emit('update:modelValue', spu?.id || 0)
emit('change', spu)
} else {
emit('update:modelValue', productSpus.value.map((spu) => spu.id))
emit(
'update:modelValue',
productSpus.value.map((spu) => spu.id)
)
emit('change', productSpus.value)
}
}

View File

@ -167,7 +167,7 @@ const open = (spuList?: Spu[]) => {
//
if (spuList && spuList.length > 0) {
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
@ -184,7 +184,9 @@ const getList = async () => {
list.value = data.list
total.value = data.total
// 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()
} finally {
@ -272,23 +274,19 @@ const handleCheckOne = (checked: boolean, spu: Spu, isCalcCheckAll: boolean) =>
}
//
if(isCalcCheckAll){
if (isCalcCheckAll) {
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 = () => {
isCheckAll.value = list.value.every(spu => {
let valueElement = checkedStatus.value[spu.id];
debugger
return valueElement;
});
isCheckAll.value = list.value.every((spu) => checkedStatus.value[spu.id])
// &&
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'
import { dateFormatter } from '@/utils/formatTime'
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
import type { GiveCouponTemplate } from '@/api/mall/product/spu'
defineOptions({ name: 'CouponSelect' })
defineProps<{
multipleSelection: GiveCouponTemplate[]
multipleSelection: CouponTemplateApi.CouponTemplateVO[]
}>()
const emit = defineEmits<{
(e: 'update:multipleSelection', v: GiveCouponTemplate[])
(e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[])
}>()
const dialogVisible = ref(false) //
const dialogTitle = ref('选择优惠卷') //
@ -210,10 +209,7 @@ const open = async () => {
defineExpose({ open }) // open
const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => {
emit(
'update:multipleSelection',
val.map((item) => ({ id: item.id, name: item.name }))
)
emit('update:multipleSelection', val)
}
const submitForm = () => {

View File

@ -187,7 +187,7 @@ import {
PromotionDiscountTypeEnum,
PromotionProductScopeEnum
} 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 { convertToInteger, formatToFraction } from '@/utils'
@ -385,5 +385,4 @@ function setProductScopeValues(data: CouponTemplateApi.CouponTemplateVO) {
}
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>