This commit is contained in:
YunaiV
2024-09-30 09:05:53 +08:00
47 changed files with 2539 additions and 299 deletions

View File

@ -5,6 +5,7 @@ export interface AppLinkGroup {
// 链接列表
links: AppLink[]
}
// APP 链接
export interface AppLink {
// 链接名称
@ -21,6 +22,8 @@ export const enum APP_LINK_TYPE_ENUM {
ACTIVITY_COMBINATION,
// 秒杀活动
ACTIVITY_SECKILL,
// 积分商城活动
ACTIVITY_POINT,
// 文章详情
ARTICLE_DETAIL,
// 优惠券详情
@ -130,6 +133,11 @@ export const APP_LINK_GROUP_LIST = [
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'

View File

@ -67,7 +67,7 @@
class="text-16px"
:style="{ color: property.fields.price.color }"
>
{{ fenToYuan(spu.price) }}
{{ fenToYuan(spu.price as any) }}
</span>
<!-- 市场价 -->
<span

View File

@ -0,0 +1,96 @@
import {ComponentStyle, DiyComponent} from '@/components/DiyEditor/util'
/** 积分商城属性 */
export interface PromotionPointProperty {
// 布局类型:单列 | 三列
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
// 商品字段
fields: {
// 商品名称
name: PromotionPointFieldProperty
// 商品简介
introduction: PromotionPointFieldProperty
// 商品价格
price: PromotionPointFieldProperty
// 市场价
marketPrice: PromotionPointFieldProperty
// 商品销量
salesCount: PromotionPointFieldProperty
// 商品库存
stock: PromotionPointFieldProperty
}
// 角标
badge: {
// 是否显示
show: boolean
// 角标图片
imgUrl: string
}
// 按钮
btnBuy: {
// 类型:文字 | 图片
type: 'text' | 'img'
// 文字
text: string
// 文字按钮:背景渐变起始颜色
bgBeginColor: string
// 文字按钮:背景渐变结束颜色
bgEndColor: string
// 图片按钮:图片地址
imgUrl: string
}
// 上圆角
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,202 @@
<template>
<div ref="containerRef" :class="`box-content min-h-30px w-full flex 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">
<el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
</div>
<!-- 商品封面图 -->
<div
:class="[
'h-140px',
{
'w-full': property.layoutType !== 'oneColSmallImg',
'w-140px': property.layoutType === 'oneColSmallImg'
}
]"
>
<el-image :src="spu.picUrl" class="h-full w-full" fit="cover" />
</div>
<div
:class="[
' 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"
: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 p-x-12px p-y-4px text-12px text-white"
>
{{ property.btnBuy.text }}
</span>
<!-- 图片按钮 -->
<el-image
v-else
:src="property.btnBuy.imgUrl"
class="h-28px w-28px rounded-full"
fit="cover"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { PromotionPointProperty } from './config'
import * as ProductSpuApi from '@/api/mall/product/spu'
import { PointActivityApi, PointActivityVO, SpuExtension0 } from '@/api/mall/promotion/point'
import { fenToYuan } from '@/utils'
/** 积分商城卡片 */
defineOptions({ name: 'PromotionPoint' })
// 定义属性
const props = defineProps<{ property: PromotionPointProperty }>()
// 商品列表
const spuList = ref<SpuExtension0[]>([])
const spuIdList = ref<number[]>([])
const pointActivityList = ref<PointActivityVO[]>([])
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)
}
// 更新 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>
<style lang="scss" scoped></style>

View File

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

View File

@ -7,6 +7,7 @@ import { isNumber } from '@/utils/is'
import { ElMessage } from 'element-plus'
import { useLocaleStore } from '@/store/modules/locale'
import { getAccessToken, getTenantId } from '@/utils/auth'
import { getUploadUrl } from '@/components/UploadFile/src/useUpload'
defineOptions({ name: 'Editor' })
@ -88,7 +89,7 @@ const editorConfig = computed((): IEditorConfig => {
scroll: true,
MENU_CONF: {
['uploadImage']: {
server: import.meta.env.VITE_UPLOAD_URL,
server: getUploadUrl(),
// 单个文件的最大体积限制,默认为 2M
maxFileSize: 5 * 1024 * 1024,
// 最多可上传几个文件,默认为 100
@ -136,7 +137,7 @@ const editorConfig = computed((): IEditorConfig => {
}
},
['uploadVideo']: {
server: import.meta.env.VITE_UPLOAD_URL,
server: getUploadUrl(),
// 单个文件的最大体积限制,默认为 10M
maxFileSize: 10 * 1024 * 1024,
// 最多可上传几个文件,默认为 100

View File

@ -25,7 +25,7 @@
<template #file="{ file }">
<img :src="file.url" class="upload-image" />
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="handlePictureCardPreview(file)">
<div class="handle-icon" @click="imagePreview(file.url!)">
<Icon icon="ep:zoom-in" />
<span>查看</span>
</div>
@ -39,16 +39,12 @@
<div class="el-upload__tip">
<slot name="tip"></slot>
</div>
<el-image-viewer
v-if="imgViewVisible"
:url-list="[viewImageUrl]"
@close="imgViewVisible = false"
/>
</div>
</template>
<script lang="ts" setup>
import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
import { ElNotification } from 'element-plus'
import { createImageViewer } from '@/components/ImageViewer'
import { propTypes } from '@/utils/propTypes'
import { useUpload } from '@/components/UploadFile/src/useUpload'
@ -56,6 +52,13 @@ import { useUpload } from '@/components/UploadFile/src/useUpload'
defineOptions({ name: 'UploadImgs' })
const message = useMessage() // 消息弹窗
// 查看图片
const imagePreview = (imgUrl: string) => {
createImageViewer({
zIndex: 9999999,
urlList: [imgUrl]
})
}
type FileTypes =
| 'image/apng'
@ -178,14 +181,6 @@ const handleExceed = () => {
type: 'warning'
})
}
// 图片预览
const viewImageUrl = ref('')
const imgViewVisible = ref(false)
const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
viewImageUrl.value = uploadFile.url!
imgViewVisible.value = true
}
</script>
<style lang="scss" scoped>

View File

@ -3,9 +3,16 @@ import CryptoJS from 'crypto-js'
import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
import axios from 'axios'
/**
* 获得上传 URL
*/
export const getUploadUrl = (): string => {
return import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/infra/file/upload'
}
export const useUpload = () => {
// 后端上传地址
const uploadUrl = import.meta.env.VITE_UPLOAD_URL
const uploadUrl = getUploadUrl()
// 是否使用前端直连上传
const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
// 重写ElUpload上传方法
@ -17,16 +24,18 @@ export const useUpload = () => {
// 1.2 获取文件预签名地址
const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
// 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传Minio 不支持)
return axios.put(presignedInfo.uploadUrl, options.file, {
headers: {
'Content-Type': options.file.type,
}
}).then(() => {
// 1.4. 记录文件信息到后端(异步)
createFile(presignedInfo, fileName, options.file)
// 通知成功,数据格式保持与后端上传的返回结果一致
return { data: presignedInfo.url }
})
return axios
.put(presignedInfo.uploadUrl, options.file, {
headers: {
'Content-Type': options.file.type
}
})
.then(() => {
// 1.4. 记录文件信息到后端(异步)
createFile(presignedInfo, fileName, options.file)
// 通知成功,数据格式保持与后端上传的返回结果一致
return { data: presignedInfo.url }
})
} else {
// 模式二:后端上传
// 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子