初始化
This commit is contained in:
207
src/components/AppLinkInput/AppLinkSelectDialog.vue
Normal file
207
src/components/AppLinkInput/AppLinkSelectDialog.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" title="选择链接" width="65%">
|
||||
<div class="h-500px flex gap-8px">
|
||||
<!-- 左侧分组列表 -->
|
||||
<el-scrollbar wrap-class="h-full" ref="groupScrollbar" view-class="flex flex-col">
|
||||
<el-button
|
||||
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
|
||||
:key="groupIndex"
|
||||
:class="[
|
||||
'm-r-16px m-l-0px! justify-start! w-90px',
|
||||
{ active: activeGroup === group.name }
|
||||
]"
|
||||
ref="groupBtnRefs"
|
||||
:text="activeGroup !== group.name"
|
||||
:type="activeGroup === group.name ? 'primary' : 'default'"
|
||||
@click="handleGroupSelected(group.name)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</el-button>
|
||||
</el-scrollbar>
|
||||
<!-- 右侧链接列表 -->
|
||||
<el-scrollbar class="h-full flex-1" @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>
|
||||
<!-- 链接列表 -->
|
||||
<el-tooltip
|
||||
v-for="(appLink, appLinkIndex) in group.links"
|
||||
:key="appLinkIndex"
|
||||
:content="appLink.path"
|
||||
placement="bottom"
|
||||
:show-after="300"
|
||||
>
|
||||
<el-button
|
||||
class="m-b-8px m-r-8px m-l-0px!"
|
||||
:type="isSameLink(appLink.path, activeAppLink.path) ? 'primary' : 'default'"
|
||||
@click="handleAppLinkSelected(appLink)"
|
||||
>
|
||||
{{ appLink.name }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<!-- 底部对话框操作按钮 -->
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Dialog v-model="detailSelectDialog.visible" title="" width="50%">
|
||||
<el-form class="min-h-200px">
|
||||
<el-form-item
|
||||
label="选择分类"
|
||||
v-if="detailSelectDialog.type === APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST"
|
||||
>
|
||||
<ProductCategorySelect
|
||||
v-model="detailSelectDialog.id"
|
||||
:parent-id="0"
|
||||
@update:model-value="handleProductCategorySelected"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM, AppLink } from './data'
|
||||
import { ButtonInstance, ScrollbarInstance } from 'element-plus'
|
||||
import { split } from 'lodash-es'
|
||||
import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue'
|
||||
import { getUrlNumberValue } from '@/utils'
|
||||
|
||||
// APP 链接选择弹框
|
||||
defineOptions({ name: 'AppLinkSelectDialog' })
|
||||
// 选中的分组,默认选中第一个
|
||||
const activeGroup = ref(APP_LINK_GROUP_LIST[0].name)
|
||||
// 选中的 APP 链接
|
||||
const activeAppLink = ref({} as AppLink)
|
||||
|
||||
/** 打开弹窗 */
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 处理绑定值更新
|
||||
const emit = defineEmits<{
|
||||
change: [link: string]
|
||||
appLinkChange: [appLink: AppLink]
|
||||
}>()
|
||||
const handleSubmit = () => {
|
||||
dialogVisible.value = false
|
||||
emit('change', activeAppLink.value.path)
|
||||
emit('appLinkChange', activeAppLink.value)
|
||||
}
|
||||
|
||||
// 分组标题引用列表
|
||||
const groupTitleRefs = ref<HTMLInputElement[]>([])
|
||||
/**
|
||||
* 处理右侧链接列表滚动
|
||||
* @param scrollTop 滚动条的位置
|
||||
*/
|
||||
const 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧滚动条
|
||||
const linkScrollbar = ref<ScrollbarInstance>()
|
||||
// 处理分组选中
|
||||
const handleGroupSelected = (group: string) => {
|
||||
activeGroup.value = group
|
||||
const titleRef = groupTitleRefs.value.find((item: HTMLInputElement) => item.textContent === group)
|
||||
if (titleRef) {
|
||||
// 滚动分组标题
|
||||
linkScrollbar.value?.setScrollTop(titleRef.offsetTop)
|
||||
}
|
||||
}
|
||||
|
||||
// 分组滚动条
|
||||
const groupScrollbar = ref<ScrollbarInstance>()
|
||||
// 分组引用列表
|
||||
const groupBtnRefs = ref<ButtonInstance[]>([])
|
||||
// 自动滚动分组按钮,确保分组按钮保持在可视区域内
|
||||
const scrollToGroupBtn = (group: string) => {
|
||||
const groupBtn = groupBtnRefs.value
|
||||
.map((btn: ButtonInstance) => btn['ref'])
|
||||
.find((ref: Node) => ref.textContent === group)
|
||||
if (groupBtn) {
|
||||
groupScrollbar.value?.setScrollTop(groupBtn.offsetTop)
|
||||
}
|
||||
}
|
||||
|
||||
// 是否为相同的链接(不比较参数,只比较链接)
|
||||
const isSameLink = (link1: string, link2: string) => {
|
||||
return split(link1, '?', 1)[0] === split(link2, '?', 1)[0]
|
||||
}
|
||||
|
||||
// 详情选择对话框
|
||||
const detailSelectDialog = ref<{
|
||||
visible: boolean
|
||||
id?: number
|
||||
type?: APP_LINK_TYPE_ENUM
|
||||
}>({
|
||||
visible: false,
|
||||
id: undefined,
|
||||
type: undefined
|
||||
})
|
||||
// 处理详情选择
|
||||
const handleProductCategorySelected = (id: number) => {
|
||||
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>
|
||||
<style lang="scss" scoped></style>
|
||||
236
src/components/AppLinkInput/data.ts
Normal file
236
src/components/AppLinkInput/data.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// APP 链接分组
|
||||
export interface AppLinkGroup {
|
||||
// 分组名称
|
||||
name: string
|
||||
// 链接列表
|
||||
links: AppLink[]
|
||||
}
|
||||
|
||||
// APP 链接
|
||||
export interface AppLink {
|
||||
// 链接名称
|
||||
name: string
|
||||
// 链接地址
|
||||
path: string
|
||||
// 链接的类型
|
||||
type?: APP_LINK_TYPE_ENUM
|
||||
}
|
||||
|
||||
// APP 链接类型(需要特殊处理,例如商品详情)
|
||||
export const enum APP_LINK_TYPE_ENUM {
|
||||
// 拼团活动
|
||||
ACTIVITY_COMBINATION,
|
||||
// 秒杀活动
|
||||
ACTIVITY_SECKILL,
|
||||
// 积分商城活动
|
||||
ACTIVITY_POINT,
|
||||
// 文章详情
|
||||
ARTICLE_DETAIL,
|
||||
// 优惠券详情
|
||||
COUPON_DETAIL,
|
||||
// 自定义页面详情
|
||||
DIY_PAGE_DETAIL,
|
||||
// 品类列表
|
||||
PRODUCT_CATEGORY_LIST,
|
||||
// 商品列表
|
||||
PRODUCT_LIST,
|
||||
// 商品详情
|
||||
PRODUCT_DETAIL_NORMAL,
|
||||
// 拼团商品详情
|
||||
PRODUCT_DETAIL_COMBINATION,
|
||||
// 秒杀商品详情
|
||||
PRODUCT_DETAIL_SECKILL
|
||||
}
|
||||
|
||||
// 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[]
|
||||
43
src/components/AppLinkInput/index.vue
Normal file
43
src/components/AppLinkInput/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<el-input v-model="appLink" placeholder="输入或选择链接">
|
||||
<template #append>
|
||||
<el-button @click="handleOpenDialog">选择</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
// APP 链接输入框
|
||||
defineOptions({ name: 'AppLinkInput' })
|
||||
// 定义属性
|
||||
const props = defineProps({
|
||||
// 当前选中的链接
|
||||
modelValue: propTypes.string.def('')
|
||||
})
|
||||
// 当前的链接
|
||||
const appLink = ref('')
|
||||
// 选择对话框
|
||||
const dialogRef = ref()
|
||||
// 处理打开对话框
|
||||
const handleOpenDialog = () => dialogRef.value?.open(appLink.value)
|
||||
// 处理 APP 链接选中
|
||||
const handleLinkSelected = (link: string) => (appLink.value = link)
|
||||
|
||||
// getter
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => (appLink.value = props.modelValue),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// setter
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [link: string]
|
||||
}>()
|
||||
watch(
|
||||
() => appLink.value,
|
||||
() => emit('update:modelValue', appLink.value)
|
||||
)
|
||||
</script>
|
||||
3
src/components/Backtop/index.ts
Normal file
3
src/components/Backtop/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Backtop from './src/Backtop.vue'
|
||||
|
||||
export { Backtop }
|
||||
17
src/components/Backtop/src/Backtop.vue
Normal file
17
src/components/Backtop/src/Backtop.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { ElBacktop } from 'element-plus'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
defineOptions({ name: 'BackTop' })
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('backtop')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElBacktop
|
||||
:class="`${prefixCls}-backtop`"
|
||||
:target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`"
|
||||
/>
|
||||
</template>
|
||||
3
src/components/Card/index.ts
Normal file
3
src/components/Card/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CardTitle from './src/CardTitle.vue'
|
||||
|
||||
export { CardTitle }
|
||||
37
src/components/Card/src/CardTitle.vue
Normal file
37
src/components/Card/src/CardTitle.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
defineComponent({
|
||||
name: 'CardTitle'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="card-title">{{ title }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
position: relative;
|
||||
top: 8px;
|
||||
left: -5px;
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
//background-color: #105cfb;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 5px;
|
||||
content: '';
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/components/ColorInput/index.vue
Normal file
34
src/components/ColorInput/index.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<el-input v-model="color">
|
||||
<template #prepend>
|
||||
<el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { PREDEFINE_COLORS } from '@/utils/color'
|
||||
|
||||
// 颜色输入框
|
||||
defineOptions({ name: 'ColorInput' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: propTypes.string.def('')
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const color = computed({
|
||||
get: () => {
|
||||
return props.modelValue
|
||||
},
|
||||
set: (val: string) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-input-group__prepend) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
3
src/components/ConfigGlobal/index.ts
Normal file
3
src/components/ConfigGlobal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ConfigGlobal from './src/ConfigGlobal.vue'
|
||||
|
||||
export { ConfigGlobal }
|
||||
62
src/components/ConfigGlobal/src/ConfigGlobal.vue
Normal file
62
src/components/ConfigGlobal/src/ConfigGlobal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, computed, watch, onMounted } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { ComponentSize, ElConfigProvider } from 'element-plus'
|
||||
import { useLocaleStore } from '@/store/modules/locale'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { setCssVar } from '@/utils'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { variables } = useDesign()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps({
|
||||
size: propTypes.oneOf<ComponentSize>(['default', 'small', 'large']).def('default')
|
||||
})
|
||||
|
||||
provide('configGlobal', props)
|
||||
|
||||
// 初始化所有主题色
|
||||
onMounted(() => {
|
||||
appStore.setCssVarTheme()
|
||||
})
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 监听窗口变化
|
||||
watch(
|
||||
() => width.value,
|
||||
(width: number) => {
|
||||
if (width < 768) {
|
||||
!appStore.getMobile ? appStore.setMobile(true) : undefined
|
||||
setCssVar('--left-menu-min-width', '0')
|
||||
appStore.setCollapse(true)
|
||||
appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
|
||||
} else {
|
||||
appStore.getMobile ? appStore.setMobile(false) : undefined
|
||||
setCssVar('--left-menu-min-width', '64px')
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
// 多语言相关
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
const currentLocale = computed(() => localeStore.currentLocale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider
|
||||
:namespace="variables.elNamespace"
|
||||
:locale="currentLocale.elLocale"
|
||||
:message="{ max: 5 }"
|
||||
:size="size"
|
||||
>
|
||||
<slot></slot>
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
3
src/components/ContentDetailWrap/index.ts
Normal file
3
src/components/ContentDetailWrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ContentDetailWrap from './src/ContentDetailWrap.vue'
|
||||
|
||||
export { ContentDetailWrap }
|
||||
58
src/components/ContentDetailWrap/src/ContentDetailWrap.vue
Normal file
58
src/components/ContentDetailWrap/src/ContentDetailWrap.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
defineOptions({ name: 'ContentDetailWrap' })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('content-detail-wrap')
|
||||
|
||||
defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def('')
|
||||
})
|
||||
const emit = defineEmits(['back'])
|
||||
const offset = ref(85)
|
||||
const contentDetailWrap = ref()
|
||||
onMounted(() => {
|
||||
offset.value = contentDetailWrap.value.getBoundingClientRect().top
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="contentDetailWrap" :class="[`${prefixCls}-container`]">
|
||||
<Sticky :offset="offset">
|
||||
<div
|
||||
:class="[
|
||||
`${prefixCls}-header`,
|
||||
'flex b-b-1 h-50px items-center text-center bg-white pr-10px'
|
||||
]"
|
||||
>
|
||||
<div :class="[`${prefixCls}-header__back`, 'flex pl-10px pr-10px ']">
|
||||
<ElButton @click="emit('back')">
|
||||
<Icon class="mr-5px" icon="ep:arrow-left" />
|
||||
{{ t('common.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div :class="[`${prefixCls}-header__title`, 'flex flex-1 justify-center']">
|
||||
<slot name="title">
|
||||
<label class="text-16px font-700">{{ title }}</label>
|
||||
</slot>
|
||||
</div>
|
||||
<div :class="[`${prefixCls}-header__right`, 'flex pl-10px pr-10px']">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Sticky>
|
||||
<div style="padding: var(--app-content-padding)">
|
||||
<ElCard :class="[`${prefixCls}-body`, 'mb-20px']" shadow="never">
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/ContentWrap/index.ts
Normal file
3
src/components/ContentWrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ContentWrap from './src/ContentWrap.vue'
|
||||
|
||||
export { ContentWrap }
|
||||
36
src/components/ContentWrap/src/ContentWrap.vue
Normal file
36
src/components/ContentWrap/src/ContentWrap.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
defineOptions({ name: 'ContentWrap' })
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('content-wrap')
|
||||
|
||||
defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def(''),
|
||||
bodyStyle: propTypes.object.def({ padding: '10px' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never">
|
||||
<template v-if="title" #header>
|
||||
<div class="flex items-center">
|
||||
<span class="text-16px font-700">{{ title }}</span>
|
||||
<ElTooltip v-if="message" effect="dark" placement="right">
|
||||
<template #content>
|
||||
<div class="max-w-200px">{{ message }}</div>
|
||||
</template>
|
||||
<Icon :size="14" class="ml-5px" icon="ep:question-filled" />
|
||||
</ElTooltip>
|
||||
<div class="flex flex-grow pl-20px">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</ElCard>
|
||||
</template>
|
||||
3
src/components/CountTo/index.ts
Normal file
3
src/components/CountTo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CountTo from './src/CountTo.vue'
|
||||
|
||||
export { CountTo }
|
||||
182
src/components/CountTo/src/CountTo.vue
Normal file
182
src/components/CountTo/src/CountTo.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { isNumber } from '@/utils/is'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
defineOptions({ name: 'CountTo' })
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('count-to')
|
||||
|
||||
const props = defineProps({
|
||||
startVal: propTypes.number.def(0), // 开始播放值
|
||||
endVal: propTypes.number.def(2021), // 最终值
|
||||
duration: propTypes.number.def(3000), // 动画时长
|
||||
autoplay: propTypes.bool.def(true), // 是否自动播放动画, 默认播放
|
||||
decimals: propTypes.number.validate((value: number) => value >= 0).def(0), // 显示的小数位数, 默认不显示小数
|
||||
decimal: propTypes.string.def('.'), // 小数分隔符号, 默认为点
|
||||
separator: propTypes.string.def(','), // 数字每三位的分隔符, 默认为逗号
|
||||
prefix: propTypes.string.def(''), // 前缀, 数值前面显示的内容
|
||||
suffix: propTypes.string.def(''), // 后缀, 数值后面显示的内容
|
||||
useEasing: propTypes.bool.def(true), // 是否使用缓动效果, 默认启用
|
||||
easingFn: {
|
||||
type: Function as PropType<(t: number, b: number, c: number, d: number) => number>,
|
||||
default(t: number, b: number, c: number, d: number) {
|
||||
return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b
|
||||
} // 缓动函数
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mounted', 'callback'])
|
||||
|
||||
const formatNumber = (num: number | string) => {
|
||||
const { decimals, decimal, separator, suffix, prefix } = props
|
||||
num = Number(num).toFixed(decimals)
|
||||
num += ''
|
||||
const x = num.split('.')
|
||||
let x1 = x[0]
|
||||
const x2 = x.length > 1 ? decimal + x[1] : ''
|
||||
const rgx = /(\d+)(\d{3})/
|
||||
if (separator && !isNumber(separator)) {
|
||||
while (rgx.test(x1)) {
|
||||
x1 = x1.replace(rgx, '$1' + separator + '$2')
|
||||
}
|
||||
}
|
||||
return prefix + x1 + x2 + suffix
|
||||
}
|
||||
|
||||
const state = reactive<{
|
||||
localStartVal: number
|
||||
printVal: number | null
|
||||
displayValue: string
|
||||
paused: boolean
|
||||
localDuration: number | null
|
||||
startTime: number | null
|
||||
timestamp: number | null
|
||||
rAF: any
|
||||
remaining: number | null
|
||||
}>({
|
||||
localStartVal: props.startVal,
|
||||
displayValue: formatNumber(props.startVal),
|
||||
printVal: null,
|
||||
paused: false,
|
||||
localDuration: props.duration,
|
||||
startTime: null,
|
||||
timestamp: null,
|
||||
remaining: null,
|
||||
rAF: null
|
||||
})
|
||||
|
||||
const displayValue = toRef(state, 'displayValue')
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoplay) {
|
||||
start()
|
||||
}
|
||||
emit('mounted')
|
||||
})
|
||||
|
||||
const getCountDown = computed(() => {
|
||||
return props.startVal > props.endVal
|
||||
})
|
||||
|
||||
watch([() => props.startVal, () => props.endVal], () => {
|
||||
if (props.autoplay) {
|
||||
start()
|
||||
}
|
||||
})
|
||||
|
||||
const start = () => {
|
||||
const { startVal, duration } = props
|
||||
state.localStartVal = startVal
|
||||
state.startTime = null
|
||||
state.localDuration = duration
|
||||
state.paused = false
|
||||
state.rAF = requestAnimationFrame(count)
|
||||
}
|
||||
|
||||
const pauseResume = () => {
|
||||
if (state.paused) {
|
||||
resume()
|
||||
state.paused = false
|
||||
} else {
|
||||
pause()
|
||||
state.paused = true
|
||||
}
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
cancelAnimationFrame(state.rAF)
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
state.startTime = null
|
||||
state.localDuration = +(state.remaining as number)
|
||||
state.localStartVal = +(state.printVal as number)
|
||||
requestAnimationFrame(count)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
state.startTime = null
|
||||
cancelAnimationFrame(state.rAF)
|
||||
state.displayValue = formatNumber(props.startVal)
|
||||
}
|
||||
|
||||
const count = (timestamp: number) => {
|
||||
const { useEasing, easingFn, endVal } = props
|
||||
if (!state.startTime) state.startTime = timestamp
|
||||
state.timestamp = timestamp
|
||||
const progress = timestamp - state.startTime
|
||||
state.remaining = (state.localDuration as number) - progress
|
||||
if (useEasing) {
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal =
|
||||
state.localStartVal -
|
||||
easingFn(progress, 0, state.localStartVal - endVal, state.localDuration as number)
|
||||
} else {
|
||||
state.printVal = easingFn(
|
||||
progress,
|
||||
state.localStartVal,
|
||||
endVal - state.localStartVal,
|
||||
state.localDuration as number
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal =
|
||||
state.localStartVal -
|
||||
(state.localStartVal - endVal) * (progress / (state.localDuration as number))
|
||||
} else {
|
||||
state.printVal =
|
||||
state.localStartVal +
|
||||
(endVal - state.localStartVal) * (progress / (state.localDuration as number))
|
||||
}
|
||||
}
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal = state.printVal < endVal ? endVal : state.printVal
|
||||
} else {
|
||||
state.printVal = state.printVal > endVal ? endVal : state.printVal
|
||||
}
|
||||
state.displayValue = formatNumber(state.printVal!)
|
||||
if (progress < (state.localDuration as number)) {
|
||||
state.rAF = requestAnimationFrame(count)
|
||||
} else {
|
||||
emit('callback')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
pauseResume,
|
||||
reset,
|
||||
start,
|
||||
pause
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="prefixCls">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
</template>
|
||||
2
src/components/Crontab/index.ts
Normal file
2
src/components/Crontab/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Crontab from './src/Crontab.vue'
|
||||
export { Crontab }
|
||||
1015
src/components/Crontab/src/Crontab.vue
Normal file
1015
src/components/Crontab/src/Crontab.vue
Normal file
File diff suppressed because it is too large
Load Diff
4
src/components/Cropper/index.ts
Normal file
4
src/components/Cropper/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import CropperImage from './src/Cropper.vue'
|
||||
import CropperAvatar from './src/CropperAvatar.vue'
|
||||
|
||||
export { CropperImage, CropperAvatar }
|
||||
262
src/components/Cropper/src/CopperModal.vue
Normal file
262
src/components/Cropper/src/CopperModal.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div @click.stop>
|
||||
<Dialog
|
||||
v-model="dialogVisible"
|
||||
:canFullscreen="false"
|
||||
:title="t('cropper.modalTitle')"
|
||||
maxHeight="380px"
|
||||
width="800px"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<div :class="`${prefixCls}-left`">
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
:src="src"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<el-upload :beforeUpload="handleBeforeUpload" :fileList="[]" accept="image/*">
|
||||
<el-tooltip :content="t('cropper.selectImage')" placement="bottom">
|
||||
<XButton preIcon="ant-design:upload-outlined" type="primary" />
|
||||
</el-tooltip>
|
||||
</el-upload>
|
||||
<el-space>
|
||||
<el-tooltip :content="t('cropper.btn_reset')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:reload-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('reset')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:rotate-left-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', -45)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:rotate-right-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="vaadin:arrows-long-h"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleX')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="vaadin:arrows-long-v"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleY')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:zoom-in-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:zoom-out-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</el-space>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img v-if="previewSource" :alt="t('cropper.preview')" :src="previewSource" />
|
||||
</div>
|
||||
<template v-if="previewSource">
|
||||
<div :class="`${prefixCls}-group`">
|
||||
<el-avatar :src="previewSource" size="large" />
|
||||
<el-avatar :size="48" :src="previewSource" />
|
||||
<el-avatar :size="64" :src="previewSource" />
|
||||
<el-avatar :size="80" :src="previewSource" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { dataURLtoBlob } from '@/utils/filt'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { CropendResult, Cropper } from './types'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { CropperImage } from '@/components/Cropper'
|
||||
|
||||
defineOptions({ name: 'CopperModal' })
|
||||
|
||||
const props = defineProps({
|
||||
srcValue: propTypes.string.def(''),
|
||||
circled: propTypes.bool.def(true)
|
||||
})
|
||||
const emit = defineEmits(['uploadSuccess'])
|
||||
const { t } = useI18n()
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-am')
|
||||
|
||||
const src = ref(props.srcValue)
|
||||
const previewSource = ref('')
|
||||
const cropper = ref<Cropper>()
|
||||
const dialogVisible = ref(false)
|
||||
let filename = ''
|
||||
let scaleX = 1
|
||||
let scaleY = 1
|
||||
|
||||
// Block upload
|
||||
function handleBeforeUpload(file: File) {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
src.value = ''
|
||||
previewSource.value = ''
|
||||
reader.onload = function (e) {
|
||||
src.value = (e.target?.result as string) ?? ''
|
||||
filename = file.name
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleCropend({ imgBase64 }: CropendResult) {
|
||||
previewSource.value = imgBase64
|
||||
}
|
||||
|
||||
function handleReady(cropperInstance: Cropper) {
|
||||
cropper.value = cropperInstance
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1
|
||||
}
|
||||
if (event === 'scaleY') {
|
||||
scaleY = arg = scaleY === -1 ? 1 : -1
|
||||
}
|
||||
cropper?.value?.[event]?.(arg)
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const blob = dataURLtoBlob(previewSource.value)
|
||||
emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename })
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
debugger
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ openModal, closeModal })
|
||||
</script>
|
||||
<style lang="scss">
|
||||
$prefix-cls: #{$namespace}-cropper-am;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
display: flex;
|
||||
|
||||
&-left,
|
||||
&-right {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
&-left {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
&-right {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
);
|
||||
background-position:
|
||||
0 0,
|
||||
12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
183
src/components/Cropper/src/Cropper.vue
Normal file
183
src/components/Cropper/src/Cropper.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { CSSProperties, PropType } from 'vue'
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
defineOptions({ name: 'Cropper' })
|
||||
|
||||
type Options = Cropper.Options
|
||||
|
||||
const defaultOptions: Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
src: propTypes.string.def(''),
|
||||
alt: propTypes.string.def(''),
|
||||
circled: propTypes.bool.def(false),
|
||||
realTimePreview: propTypes.bool.def(true),
|
||||
height: propTypes.string.def('360px'),
|
||||
crossorigin: {
|
||||
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
|
||||
default: undefined
|
||||
},
|
||||
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
|
||||
options: { type: Object as PropType<Options>, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError'])
|
||||
const attrs = useAttrs()
|
||||
const imgElRef = ref<ElRef<HTMLImageElement>>()
|
||||
const cropper = ref<Nullable<Cropper>>()
|
||||
const isReady = ref(false)
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-image')
|
||||
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle
|
||||
}
|
||||
})
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled
|
||||
}
|
||||
]
|
||||
})
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${props.height}`.replace(/px/, '') + 'px' }
|
||||
})
|
||||
|
||||
onMounted(init)
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy()
|
||||
})
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef)
|
||||
if (!imgEl) {
|
||||
return
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true
|
||||
realTimeCroppered()
|
||||
emit('ready', cropper.value)
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCroppered() {
|
||||
props.realTimePreview && croppered()
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function croppered() {
|
||||
if (!cropper.value) {
|
||||
return
|
||||
}
|
||||
let imgInfo = cropper.value.getData()
|
||||
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return
|
||||
}
|
||||
let fileReader: FileReader = new FileReader()
|
||||
fileReader.readAsDataURL(blob)
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo
|
||||
})
|
||||
}
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas()
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')!
|
||||
const width = sourceCanvas.width
|
||||
const height = sourceCanvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
context.imageSmoothingEnabled = true
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height)
|
||||
context.globalCompositeOperation = 'destination-in'
|
||||
context.beginPath()
|
||||
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true)
|
||||
context.fill()
|
||||
return canvas
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
$prefix-cls: #{$namespace}-cropper-image;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
src/components/Cropper/src/CropperAvatar.vue
Normal file
142
src/components/Cropper/src/CropperAvatar.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="user-info-head" @click="open()">
|
||||
<el-avatar v-if="sourceValue" :src="sourceValue" alt="avatar" class="img-circle img-lg" />
|
||||
<el-avatar v-if="!sourceValue" :src="avatar" alt="avatar" class="img-circle img-lg" />
|
||||
<el-button v-if="showBtn" :class="`${prefixCls}-upload-btn`" @click="open()">
|
||||
{{ btnText ? btnText : t('cropper.selectImage') }}
|
||||
</el-button>
|
||||
<CopperModal
|
||||
ref="cropperModelRef"
|
||||
:srcValue="sourceValue"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CopperModal from './CopperModal.vue'
|
||||
import avatar from '@/assets/imgs/avatar.gif'
|
||||
|
||||
defineOptions({ name: 'CropperAvatar' })
|
||||
|
||||
const props = defineProps({
|
||||
width: propTypes.string.def('200px'),
|
||||
value: propTypes.string.def(''),
|
||||
showBtn: propTypes.bool.def(true),
|
||||
btnText: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value', 'change'])
|
||||
const sourceValue = ref(props.value)
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-avatar')
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const cropperModelRef = ref()
|
||||
|
||||
watchEffect(() => {
|
||||
sourceValue.value = props.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => sourceValue.value,
|
||||
(v: string) => {
|
||||
emit('update:value', v)
|
||||
}
|
||||
)
|
||||
|
||||
function handleUploadSuccess({ source, data, filename }) {
|
||||
sourceValue.value = source
|
||||
emit('change', { source, data, filename })
|
||||
message.success(t('cropper.uploadSuccess'))
|
||||
}
|
||||
|
||||
function open() {
|
||||
cropperModelRef.value.openModal()
|
||||
}
|
||||
|
||||
function close() {
|
||||
cropperModelRef.value.closeModal()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}--cropper-avatar;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&-image-wrapper {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask {
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
cursor: pointer;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
border: inherit;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
::v-deep(svg) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask:hover {
|
||||
opacity: 40;
|
||||
}
|
||||
|
||||
&-upload-btn {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-head {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.img-lg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.user-info-head:hover::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
font-size: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-style: normal;
|
||||
line-height: 110px;
|
||||
color: #eee;
|
||||
cursor: pointer;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
border-radius: 50%;
|
||||
content: '+';
|
||||
}
|
||||
</style>
|
||||
8
src/components/Cropper/src/types.ts
Normal file
8
src/components/Cropper/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type Cropper from 'cropperjs'
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string
|
||||
imgInfo: Cropper.Data
|
||||
}
|
||||
|
||||
export type { Cropper }
|
||||
122
src/components/DeptSelectForm/index.vue
Normal file
122
src/components/DeptSelectForm/index.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" title="部门选择" width="600">
|
||||
<el-row v-loading="formLoading">
|
||||
<el-col :span="24">
|
||||
<ContentWrap class="h-1/1">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="deptTree"
|
||||
:props="defaultProps"
|
||||
show-checkbox
|
||||
:check-strictly="checkStrictly"
|
||||
check-on-click-node
|
||||
default-expand-all
|
||||
highlight-current
|
||||
node-key="id"
|
||||
@check="handleCheck"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<template #footer>
|
||||
<el-button
|
||||
:disabled="formLoading || !selectedDeptIds?.length"
|
||||
type="primary"
|
||||
@click="submitForm"
|
||||
>
|
||||
确 定
|
||||
</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defaultProps, handleTree } from '@/utils/tree'
|
||||
import * as DeptApi from '@/api/system/dept'
|
||||
|
||||
defineOptions({ name: 'DeptSelectForm' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [deptList: any[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const props = defineProps({
|
||||
// 是否严格的遵循父子不互相关联
|
||||
checkStrictly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否支持多选
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const treeRef = ref()
|
||||
const deptTree = ref<Tree[]>([]) // 部门树形结构
|
||||
const selectedDeptIds = ref<number[]>([]) // 选中的部门 ID 列表
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const formLoading = ref(false) // 表单的加载中
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (selectedList?: DeptApi.DeptVO[]) => {
|
||||
resetForm()
|
||||
formLoading.value = true
|
||||
try {
|
||||
// 加载部门列表
|
||||
const deptData = await DeptApi.getSimpleDeptList()
|
||||
deptTree.value = handleTree(deptData)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
dialogVisible.value = true
|
||||
// 设置已选择的部门
|
||||
if (selectedList?.length) {
|
||||
await nextTick()
|
||||
const selectedIds = selectedList
|
||||
.map((dept) => dept.id)
|
||||
.filter((id): id is number => id !== undefined)
|
||||
selectedDeptIds.value = selectedIds
|
||||
treeRef.value?.setCheckedKeys(selectedIds)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理选中状态变化 */
|
||||
const handleCheck = (data: any, checked: any) => {
|
||||
selectedDeptIds.value = treeRef.value.getCheckedKeys()
|
||||
if (!props.multiple && selectedDeptIds.value.length > 1) {
|
||||
// 单选模式下,只保留最后选择的节点
|
||||
const lastSelectedId = selectedDeptIds.value[selectedDeptIds.value.length - 1]
|
||||
selectedDeptIds.value = [lastSelectedId]
|
||||
treeRef.value.setCheckedKeys([lastSelectedId])
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交选择 */
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
// 获取选中的完整部门数据
|
||||
const checkedNodes = treeRef.value.getCheckedNodes()
|
||||
message.success(t('common.updateSuccess'))
|
||||
dialogVisible.value = false
|
||||
emit('confirm', checkedNodes)
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
deptTree.value = []
|
||||
selectedDeptIds.value = []
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys([])
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
</script>
|
||||
4
src/components/Descriptions/index.ts
Normal file
4
src/components/Descriptions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import Descriptions from './src/Descriptions.vue'
|
||||
import DescriptionsItemLabel from './src/DescriptionsItemLabel.vue'
|
||||
|
||||
export { Descriptions, DescriptionsItemLabel }
|
||||
167
src/components/Descriptions/src/Descriptions.vue
Normal file
167
src/components/Descriptions/src/Descriptions.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { DescriptionsSchema } from '@/types/descriptions'
|
||||
|
||||
defineOptions({ name: 'Descriptions' })
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const mobile = computed(() => appStore.getMobile)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const props = defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def(''),
|
||||
collapse: propTypes.bool.def(true),
|
||||
columns: propTypes.number.def(1),
|
||||
schema: {
|
||||
type: Array as PropType<DescriptionsSchema[]>,
|
||||
default: () => []
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<any>,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('descriptions')
|
||||
|
||||
const getBindValue = computed(() => {
|
||||
const delArr: string[] = ['title', 'message', 'collapse', 'schema', 'data', 'class']
|
||||
const obj = { ...attrs, ...props }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
const getBindItemValue = (item: DescriptionsSchema) => {
|
||||
const delArr: string[] = ['field']
|
||||
const obj = { ...item }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// 折叠
|
||||
const show = ref(true)
|
||||
|
||||
const toggleClick = () => {
|
||||
if (props.collapse) {
|
||||
show.value = !unref(show)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
prefixCls,
|
||||
'bg-[var(--el-color-white)] dark:bg-[var(--el-bg-color)] dark:border-[var(--el-border-color)] dark:border-1px'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
:class="[
|
||||
`${prefixCls}-header`,
|
||||
'h-50px flex justify-between items-center b-b-1 border-solid border-[var(--el-border-color)] px-10px cursor-pointer dark:border-[var(--el-border-color)]'
|
||||
]"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<div :class="[`${prefixCls}-header__title`, 'relative font-18px font-bold ml-10px']">
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<ElTooltip v-if="message" :content="message" placement="right">
|
||||
<Icon class="ml-5px" icon="ep:warning" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Icon v-if="collapse" :icon="show ? 'ep:arrow-down' : 'ep:arrow-up'" />
|
||||
</div>
|
||||
|
||||
<ElCollapseTransition>
|
||||
<div v-show="show" :class="[`${prefixCls}-content`, 'p-10px']">
|
||||
<ElDescriptions
|
||||
:column="props.columns"
|
||||
:direction="mobile ? 'vertical' : 'horizontal'"
|
||||
border
|
||||
v-bind="getBindValue"
|
||||
>
|
||||
<template v-if="slots['extra']" #extra>
|
||||
<slot name="extra"></slot>
|
||||
</template>
|
||||
<ElDescriptionsItem
|
||||
v-for="item in schema"
|
||||
:key="item.field"
|
||||
min-width="80"
|
||||
v-bind="getBindItemValue(item)"
|
||||
>
|
||||
<template #label>
|
||||
<slot
|
||||
:name="`${item.field}-label`"
|
||||
:row="{
|
||||
label: item.label
|
||||
}"
|
||||
>{{ item.label }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<slot v-if="item.dateFormat">
|
||||
{{
|
||||
data[item.field] !== null ? dayjs(data[item.field]).format(item.dateFormat) : ''
|
||||
}}
|
||||
</slot>
|
||||
<slot v-else-if="item.dictType">
|
||||
<DictTag :type="item.dictType" :value="data[item.field] + ''" />
|
||||
</slot>
|
||||
<slot v-else :name="item.field" :row="data">
|
||||
{{
|
||||
item.mappedField ? data[item.mappedField] : data[item.field]
|
||||
}}
|
||||
</slot>
|
||||
</template>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</div>
|
||||
</ElCollapseTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}-descriptions;
|
||||
|
||||
.#{$prefix-cls}-header {
|
||||
&__title {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: -10px;
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background: var(--el-color-primary);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$prefix-cls}-content {
|
||||
:deep(.#{$elNamespace}-descriptions__cell) {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
src/components/Descriptions/src/DescriptionsItemLabel.vue
Normal file
29
src/components/Descriptions/src/DescriptionsItemLabel.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const { label } = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cell-item">
|
||||
<Icon :icon="icon" v-if="icon" style="vertical-align: middle" :size="18" />
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cell-item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.cell-item::after {
|
||||
content: ':';
|
||||
}
|
||||
</style>
|
||||
3
src/components/Dialog/index.ts
Normal file
3
src/components/Dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Dialog from './src/Dialog.vue'
|
||||
|
||||
export { Dialog }
|
||||
151
src/components/Dialog/src/Dialog.vue
Normal file
151
src/components/Dialog/src/Dialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { isNumber } from '@/utils/is'
|
||||
|
||||
defineOptions({ name: 'Dialog' })
|
||||
|
||||
const slots = useSlots()
|
||||
const emits = defineEmits(['update:modelValue'])
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: propTypes.bool.def(false),
|
||||
title: propTypes.string.def('Dialog'),
|
||||
fullscreen: propTypes.bool.def(true),
|
||||
width: propTypes.oneOfType([String, Number]).def('40%'),
|
||||
scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度
|
||||
maxHeight: propTypes.oneOfType([String, Number]).def('400px')
|
||||
})
|
||||
|
||||
const getBindValue = computed(() => {
|
||||
const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody']
|
||||
const attrs = useAttrs()
|
||||
const obj = { ...attrs, ...props }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
const toggleFull = () => {
|
||||
isFullscreen.value = !unref(isFullscreen)
|
||||
}
|
||||
|
||||
const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
|
||||
|
||||
watch(
|
||||
() => isFullscreen.value,
|
||||
async (val: boolean) => {
|
||||
await nextTick()
|
||||
if (val) {
|
||||
const windowHeight = document.documentElement.offsetHeight
|
||||
dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
|
||||
} else {
|
||||
dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const dialogStyle = computed(() => {
|
||||
return {
|
||||
height: unref(dialogHeight)
|
||||
}
|
||||
})
|
||||
|
||||
const closing = ref(false)
|
||||
|
||||
function closeHandler() {
|
||||
emits('update:modelValue', false)
|
||||
closing.value = true
|
||||
}
|
||||
|
||||
function closedHandler() {
|
||||
closing.value = false
|
||||
}
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 768)
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
console.log(isMobile.value)
|
||||
|
||||
if (isMobile.value) {
|
||||
isFullscreen.value = true
|
||||
}else{
|
||||
isFullscreen.value = false
|
||||
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog v-bind="getBindValue" :close-on-click-modal="true" :fullscreen="isFullscreen" :width="width"
|
||||
destroy-on-close lock-scroll draggable class="com-dialog" :show-close="false" @close="closeHandler"
|
||||
@closed="closedHandler">
|
||||
<template #header="{ close }">
|
||||
<div class="relative h-54px flex items-center justify-between pl-15px pr-15px">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
<div class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center justify-between">
|
||||
<Icon v-if="fullscreen" class="is-hover mr-10px cursor-pointer"
|
||||
:icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
|
||||
color="var(--el-color-info)" hover-color="var(--el-color-primary)" @click="toggleFull" />
|
||||
<Icon class="is-hover cursor-pointer" icon="ep:close" hover-color="var(--el-color-primary)"
|
||||
color="var(--el-color-info)" @click.stop="close" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElScrollbar v-if="scroll" :style="dialogStyle">
|
||||
<slot></slot>
|
||||
</ElScrollbar>
|
||||
<slot v-else></slot>
|
||||
<template v-if="slots.footer" #footer>
|
||||
<div :style="{ 'pointer-events': closing ? 'none' : 'auto' }">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.com-dialog {
|
||||
.#{$elNamespace}-overlay-dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.#{$elNamespace}-dialog {
|
||||
margin: 0 !important;
|
||||
|
||||
&__header {
|
||||
height: 54px;
|
||||
padding: 0;
|
||||
margin-right: 0 !important;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 15px !important;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
&__headerbtn {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/components/DictTag/index.ts
Normal file
3
src/components/DictTag/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DictTag from './src/DictTag.vue'
|
||||
|
||||
export { DictTag }
|
||||
90
src/components/DictTag/src/DictTag.vue
Normal file
90
src/components/DictTag/src/DictTag.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="tsx">
|
||||
import { computed, defineComponent, PropType } from 'vue'
|
||||
import { isHexColor } from '@/utils/color'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { DictDataType, getDictOptions } from '@/utils/dict'
|
||||
import { isArray, isBoolean, isNumber, isString } from '@/utils/is'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DictTag',
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<string>,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: [String, Number, Boolean, Array],
|
||||
required: true
|
||||
},
|
||||
// 字符串分隔符 只有当 props.value 传入值为字符串时有效
|
||||
separator: {
|
||||
type: String as PropType<string>,
|
||||
default: ','
|
||||
},
|
||||
// 每个 tag 之间的间隔,默认为 5px,参考的 el-row 的 gutter
|
||||
gutter: {
|
||||
type: String as PropType<string>,
|
||||
default: '5px'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const valueArr: any = computed(() => {
|
||||
// 1. 是 Number 类型和 Boolean 类型的情况
|
||||
if (isNumber(props.value) || isBoolean(props.value)) {
|
||||
return [String(props.value)]
|
||||
}
|
||||
// 2. 是字符串(进一步判断是否有包含分隔符号 -> props.sepSymbol )
|
||||
else if (isString(props.value)) {
|
||||
return props.value.split(props.separator)
|
||||
}
|
||||
// 3. 数组
|
||||
else if (isArray(props.value)) {
|
||||
return props.value.map(String)
|
||||
}
|
||||
return []
|
||||
})
|
||||
const renderDictTag = () => {
|
||||
if (!props.type) {
|
||||
return null
|
||||
}
|
||||
// 解决自定义字典标签值为零时标签不渲染的问题
|
||||
if (props.value === undefined || props.value === null || props.value === '') {
|
||||
return null
|
||||
}
|
||||
const dictOptions = getDictOptions(props.type)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="dict-tag"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
gap: props.gutter,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{dictOptions.map((dict: DictDataType) => {
|
||||
if (valueArr.value.includes(dict.value)) {
|
||||
if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
|
||||
dict.colorType = ''
|
||||
}
|
||||
return (
|
||||
// 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
|
||||
<ElTag
|
||||
style={dict?.cssClass ? 'color: #fff' : ''}
|
||||
type={dict?.colorType || null}
|
||||
color={dict?.cssClass && isHexColor(dict?.cssClass) ? dict?.cssClass : ''}
|
||||
disableTransitions={true}
|
||||
>
|
||||
{dict?.label}
|
||||
</ElTag>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return () => renderDictTag()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
239
src/components/DiyEditor/components/ComponentContainer.vue
Normal file
239
src/components/DiyEditor/components/ComponentContainer.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div :class="['component', { active: 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">
|
||||
<el-tooltip content="上移" placement="right">
|
||||
<el-button :disabled="!canMoveUp" @click.stop="handleMoveComponent(-1)">
|
||||
<Icon icon="ep:arrow-up" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="下移" placement="right">
|
||||
<el-button :disabled="!canMoveDown" @click.stop="handleMoveComponent(1)">
|
||||
<Icon icon="ep:arrow-down" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="复制" placement="right">
|
||||
<el-button @click.stop="handleCopyComponent()">
|
||||
<Icon icon="ep:copy-document" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="right">
|
||||
<el-button @click.stop="handleDeleteComponent()">
|
||||
<Icon icon="ep:delete" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</VerticalButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// 注册所有的组件
|
||||
import { components } from '../components/mobile/index'
|
||||
export default {
|
||||
components: { ...components }
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts">
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { object } from 'vue-types'
|
||||
|
||||
/**
|
||||
* 组件容器:目前在中间部分
|
||||
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
|
||||
*/
|
||||
defineOptions({ name: 'ComponentContainer' })
|
||||
|
||||
type DiyComponentWithStyle = DiyComponent<any> & { property: { style?: ComponentStyle } }
|
||||
const props = defineProps({
|
||||
component: object<DiyComponentWithStyle>().isRequired,
|
||||
active: propTypes.bool.def(false),
|
||||
canMoveUp: propTypes.bool.def(false),
|
||||
canMoveDown: propTypes.bool.def(false),
|
||||
showToolbar: propTypes.bool.def(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件样式
|
||||
*/
|
||||
const style = computed(() => {
|
||||
let 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})`
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'move', direction: number): void
|
||||
(e: 'copy'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 移动组件
|
||||
* @param direction 移动方向
|
||||
*/
|
||||
const handleMoveComponent = (direction: number) => {
|
||||
emits('move', direction)
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制组件
|
||||
*/
|
||||
const handleCopyComponent = () => {
|
||||
emits('copy')
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除组件
|
||||
*/
|
||||
const handleDeleteComponent = () => {
|
||||
emits('delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<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(--el-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;
|
||||
color: #6a6a6a;
|
||||
line-height: 25px;
|
||||
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;
|
||||
border: 5px solid transparent;
|
||||
border-left-color: #fff;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧:组件操作工具栏 */
|
||||
.component-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: $toolbar-position;
|
||||
display: none;
|
||||
|
||||
/* 左侧小三角 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: -10px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 5px solid transparent;
|
||||
border-right-color: #2d8cf0;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 组件选中时 */
|
||||
&.active {
|
||||
margin-bottom: 4px;
|
||||
|
||||
.component-wrap {
|
||||
margin-bottom: $active-border-width + $active-border-width;
|
||||
border: $active-border-width solid var(--el-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(--el-color-primary);
|
||||
|
||||
&::after {
|
||||
border-left-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.component-toolbar {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<el-tabs stretch>
|
||||
<!-- 每个组件的自定义内容 -->
|
||||
<el-tab-pane label="内容" v-if="$slots.default">
|
||||
<slot></slot>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 每个组件的通用内容 -->
|
||||
<el-tab-pane label="样式" lazy>
|
||||
<el-card header="组件样式" class="property-group">
|
||||
<el-form :model="formData" label-width="80px">
|
||||
<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>
|
||||
<UploadImg v-model="formData.bgImg" :limit="1">
|
||||
<template #tip>建议宽度 750px</template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-tree :data="treeData" :expand-on-click-node="false" default-expand-all>
|
||||
<template #default="{ node, data }">
|
||||
<el-form-item
|
||||
:label="data.label"
|
||||
:prop="data.prop"
|
||||
:label-width="node.level === 1 ? '80px' : '62px'"
|
||||
class="w-full m-b-0!"
|
||||
>
|
||||
<el-slider
|
||||
v-model="formData[data.prop]"
|
||||
:max="100"
|
||||
:min="0"
|
||||
show-input
|
||||
input-size="small"
|
||||
:show-input-controls="false"
|
||||
@input="handleSliderChange(data.prop)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-tree>
|
||||
<slot name="style" :style="formData"></slot>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ComponentStyle } from '@/components/DiyEditor/util'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 组件容器属性:目前右边部分
|
||||
* 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
|
||||
*/
|
||||
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 '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
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-slider__runway) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 50px;
|
||||
}
|
||||
</style>
|
||||
211
src/components/DiyEditor/components/ComponentLibrary.vue
Normal file
211
src/components/DiyEditor/components/ComponentLibrary.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<el-aside class="editor-left" width="261px">
|
||||
<el-scrollbar>
|
||||
<el-collapse v-model="extendGroups">
|
||||
<el-collapse-item
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
:name="group.name"
|
||||
:title="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">
|
||||
<Icon :icon="element.icon" :size="32" />
|
||||
<span class="mt-4px text-12px">{{ element.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import { componentConfigs } from '../components/mobile/index'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { DiyComponent, DiyComponentLibrary } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 组件库:目前左侧的【基础组件】、【图文组件】部分 */
|
||||
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((component) => component)
|
||||
if (components.length > 0) {
|
||||
groups.push({
|
||||
name: group.name,
|
||||
components
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
// 克隆组件
|
||||
const handleCloneComponent = (component: DiyComponent<any>) => {
|
||||
const instance = cloneDeep(component)
|
||||
instance.uid = new Date().getTime()
|
||||
return instance
|
||||
}
|
||||
</script>
|
||||
|
||||
<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(.el-collapse) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__wrap) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
line-height: 32px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.component-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.component {
|
||||
display: flex;
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
cursor: move;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-icon {
|
||||
margin-bottom: 4px;
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.component.active,
|
||||
.component:hover {
|
||||
color: var(--el-color-white);
|
||||
background: var(--el-color-primary);
|
||||
|
||||
.el-icon {
|
||||
color: var(--el-color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.component:nth-of-type(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽占位提示,默认不显示 */
|
||||
.drag-placement {
|
||||
display: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.drag-area {
|
||||
/* 拖拽到手机区域时的样式 */
|
||||
.draggable-ghost {
|
||||
display: flex;
|
||||
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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 轮播图属性 */
|
||||
export interface CarouselProperty {
|
||||
// 类型:默认 | 卡片
|
||||
type: 'default' | 'card'
|
||||
// 指示器样式:点 | 数字
|
||||
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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<!-- 无图片 -->
|
||||
<div
|
||||
class="h-250px flex items-center justify-center bg-gray-3"
|
||||
v-if="property.items.length === 0"
|
||||
>
|
||||
<Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<el-carousel
|
||||
height="174px"
|
||||
:type="property.type === 'card' ? 'card' : ''"
|
||||
:autoplay="property.autoplay"
|
||||
:interval="property.interval * 1000"
|
||||
:indicator-position="property.indicator === 'number' ? 'none' : undefined"
|
||||
@change="handleIndexChange"
|
||||
>
|
||||
<el-carousel-item v-for="(item, index) in property.items" :key="index">
|
||||
<el-image class="h-full w-full" :src="item.imgUrl" />
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
<div
|
||||
v-if="property.indicator === 'number'"
|
||||
class="absolute bottom-10px right-10px rounded-xl bg-black p-x-8px p-y-2px text-10px text-white opacity-40"
|
||||
>{{ currentIndex }} / {{ property.items.length }}</div
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CarouselProperty } from './config'
|
||||
|
||||
/** 轮播图 */
|
||||
defineOptions({ name: 'Carousel' })
|
||||
|
||||
defineProps<{ property: CarouselProperty }>()
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const handleIndexChange = (index: number) => {
|
||||
currentIndex.value = index + 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
106
src/components/DiyEditor/components/mobile/Carousel/property.vue
Normal file
106
src/components/DiyEditor/components/mobile/Carousel/property.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-card header="样式设置" class="property-group" shadow="never">
|
||||
<el-form-item label="样式" prop="type">
|
||||
<el-radio-group v-model="formData.type">
|
||||
<el-tooltip class="item" content="默认" placement="bottom">
|
||||
<el-radio-button value="default">
|
||||
<Icon icon="system-uicons:carousel" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip class="item" content="卡片" placement="bottom">
|
||||
<el-radio-button value="card">
|
||||
<Icon icon="ic:round-view-carousel" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="指示器" prop="indicator">
|
||||
<el-radio-group v-model="formData.indicator">
|
||||
<el-radio value="dot">小圆点</el-radio>
|
||||
<el-radio value="number">数字</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否轮播" prop="autoplay">
|
||||
<el-switch v-model="formData.autoplay" />
|
||||
</el-form-item>
|
||||
<el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay">
|
||||
<el-slider
|
||||
v-model="formData.interval"
|
||||
:max="10"
|
||||
:min="0.5"
|
||||
:step="0.5"
|
||||
show-input
|
||||
input-size="small"
|
||||
:show-input-controls="false"
|
||||
/>
|
||||
<el-text type="info">单位:秒</el-text>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
<el-card header="内容设置" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.items" :empty-item="{ type: 'img' }">
|
||||
<template #default="{ element }">
|
||||
<el-form-item label="类型" prop="type" class="m-b-8px!" label-width="40px">
|
||||
<el-radio-group v-model="element.type">
|
||||
<el-radio value="img">图片</el-radio>
|
||||
<el-radio value="video">视频</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="图片"
|
||||
class="m-b-8px!"
|
||||
label-width="40px"
|
||||
v-if="element.type === 'img'"
|
||||
>
|
||||
<UploadImg
|
||||
v-model="element.imgUrl"
|
||||
draggable="false"
|
||||
height="80px"
|
||||
width="100%"
|
||||
class="min-w-80px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="封面" class="m-b-8px!" label-width="40px">
|
||||
<UploadImg
|
||||
v-model="element.imgUrl"
|
||||
draggable="false"
|
||||
height="80px"
|
||||
width="100%"
|
||||
class="min-w-80px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="视频" class="m-b-8px!" label-width="40px">
|
||||
<UploadFile
|
||||
v-model="element.videoUrl"
|
||||
:file-type="['mp4']"
|
||||
:limit="1"
|
||||
:file-size="100"
|
||||
class="min-w-80px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="链接" class="m-b-8px!" label-width="40px">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CarouselProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
// 轮播图属性面板
|
||||
defineOptions({ name: 'CarouselProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: CarouselProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
|
||||
import { CouponTemplateValidityTypeEnum, PromotionDiscountTypeEnum } from '@/utils/constants'
|
||||
import { floatToFixed2 } from '@/utils'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import { object } from 'vue-types'
|
||||
|
||||
// 优惠值
|
||||
export const CouponDiscount = defineComponent({
|
||||
name: 'CouponDiscount',
|
||||
props: {
|
||||
coupon: object<CouponTemplateApi.CouponTemplateVO>()
|
||||
},
|
||||
setup(props) {
|
||||
const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO
|
||||
// 折扣
|
||||
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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 优惠描述
|
||||
export const CouponDiscountDesc = defineComponent({
|
||||
name: 'CouponDiscountDesc',
|
||||
props: {
|
||||
coupon: object<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 / 10.0}折`
|
||||
return () => (
|
||||
<div>
|
||||
<span>{useCondition}</span>
|
||||
<span>{discountDesc}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 有效期
|
||||
export const CouponValidTerm = defineComponent({
|
||||
name: 'CouponValidTerm',
|
||||
props: {
|
||||
coupon: object<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>
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
149
src/components/DiyEditor/components/mobile/CouponCard/index.vue
Normal file
149
src/components/DiyEditor/components/mobile/CouponCard/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<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"
|
||||
>
|
||||
<!-- 布局1:1列-->
|
||||
<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>
|
||||
<!-- 布局2:2列-->
|
||||
<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" />
|
||||
<!-- 优惠描述 -->
|
||||
<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-20px rounded-20px p-x-2px p-y-8px text-center"
|
||||
:style="{
|
||||
color: property.button.color,
|
||||
background: property.button.bgColor
|
||||
}"
|
||||
>
|
||||
立即领取
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 布局3:3列-->
|
||||
<div v-else class="flex flex-col items-center justify-around gap-4px p-4px">
|
||||
<!-- 优惠值 -->
|
||||
<CouponDiscount :coupon="coupon" />
|
||||
<!-- 优惠描述 -->
|
||||
<CouponDiscountDesc :coupon="coupon" />
|
||||
<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 - 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>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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 :value="1">
|
||||
<Icon icon="fluent:text-column-one-24-filled" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip class="item" content="二列" placement="bottom">
|
||||
<el-radio-button :value="2">
|
||||
<Icon icon="fluent:text-column-two-24-filled" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip class="item" content="三列" placement="bottom">
|
||||
<el-radio-button :value="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"
|
||||
:take-type="CouponTemplateTakeTypeEnum.USER.type"
|
||||
@change="handleCouponSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CouponCardProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
|
||||
import { floatToFixed2 } from '@/utils'
|
||||
import { CouponTemplateTakeTypeEnum, 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 = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 优惠券列表
|
||||
const couponList = ref<CouponTemplateApi.CouponTemplateVO[]>([])
|
||||
const couponSelectDialog = ref()
|
||||
// 添加优惠券
|
||||
const handleAddCoupon = () => {
|
||||
couponSelectDialog.value.open()
|
||||
}
|
||||
const handleCouponSelect = () => {
|
||||
formData.value.couponIds = couponList.value.map((coupon) => coupon.id)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formData.value.couponIds,
|
||||
async () => {
|
||||
if (formData.value.couponIds?.length > 0) {
|
||||
couponList.value = await CouponTemplateApi.getCouponTemplateList(formData.value.couponIds)
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
29
src/components/DiyEditor/components/mobile/Divider/config.ts
Normal file
29
src/components/DiyEditor/components/mobile/Divider/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 分割线属性 */
|
||||
export interface DividerProperty {
|
||||
// 高度
|
||||
height: number
|
||||
// 线宽
|
||||
lineWidth: number
|
||||
// 边距类型
|
||||
paddingType: 'none' | 'horizontal'
|
||||
// 颜色
|
||||
lineColor: string
|
||||
// 类型
|
||||
borderType: 'solid' | 'dashed' | 'dotted' | 'none'
|
||||
}
|
||||
|
||||
// 定义组件
|
||||
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>
|
||||
29
src/components/DiyEditor/components/mobile/Divider/index.vue
Normal file
29
src/components/DiyEditor/components/mobile/Divider/index.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DividerProperty } from './config'
|
||||
|
||||
/** 页面顶部导航栏 */
|
||||
defineOptions({ name: 'Divider' })
|
||||
|
||||
defineProps<{ property: DividerProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-form-item label="高度" prop="height">
|
||||
<el-slider v-model="formData.height" :min="1" :max="100" show-input input-size="small" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择样式" prop="borderType">
|
||||
<el-radio-group v-model="formData!.borderType">
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
v-for="(item, index) in BORDER_TYPES"
|
||||
:key="index"
|
||||
:content="item.text"
|
||||
>
|
||||
<el-radio-button :value="item.type">
|
||||
<Icon :icon="item.icon" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<template v-if="formData.borderType !== 'none'">
|
||||
<el-form-item label="线宽" prop="lineWidth">
|
||||
<el-slider v-model="formData.lineWidth" :min="1" :max="30" show-input input-size="small" />
|
||||
</el-form-item>
|
||||
<el-form-item label="左右边距" prop="paddingType">
|
||||
<el-radio-group v-model="formData!.paddingType">
|
||||
<el-tooltip content="无边距" placement="top">
|
||||
<el-radio-button value="none">
|
||||
<Icon icon="tabler:box-padding" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="左右留边" placement="top">
|
||||
<el-radio-button value="horizontal">
|
||||
<Icon icon="vaadin:padding" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<!-- 分割线颜色 -->
|
||||
<ColorInput v-model="formData.lineColor" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DividerProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
// 导航栏属性面板
|
||||
defineOptions({ name: 'DividerProperty' })
|
||||
const props = defineProps<{ modelValue: DividerProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
|
||||
//线类型
|
||||
const BORDER_TYPES = [
|
||||
{
|
||||
icon: 'vaadin:line-h',
|
||||
text: '实线',
|
||||
type: 'solid'
|
||||
},
|
||||
{
|
||||
icon: 'tabler:line-dashed',
|
||||
text: '虚线',
|
||||
type: 'dashed'
|
||||
},
|
||||
{
|
||||
icon: 'tabler:line-dotted',
|
||||
text: '点线',
|
||||
type: 'dotted'
|
||||
},
|
||||
{
|
||||
icon: 'entypo:progress-empty',
|
||||
text: '无',
|
||||
type: 'none'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DiyComponent } from '@/components/DiyEditor/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>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'absolute bottom-32px right-[calc(50%-375px/2+32px)] flex z-12 gap-12px items-center',
|
||||
{
|
||||
'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)"
|
||||
>
|
||||
<el-image :src="item.imgUrl" fit="contain" class="h-27px w-27px">
|
||||
<template #error>
|
||||
<div class="h-full w-full flex items-center justify-center">
|
||||
<Icon icon="ep:picture" :color="item.textColor" />
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<span v-if="property.showText" class="mt-4px text-12px" :style="{ color: item.textColor }">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- todo: @owen 使用APP主题色 -->
|
||||
<el-button type="primary" size="large" circle @click="handleToggleFab">
|
||||
<Icon icon="ep:plus" :class="['fab-icon', { active: expanded }]" />
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 模态背景:展开时显示,点击后折叠 -->
|
||||
<div v-if="expanded" class="modal-bg" @click="handleToggleFab"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { FloatingActionButtonProperty } from './config'
|
||||
|
||||
/** 悬浮按钮 */
|
||||
defineOptions({ name: 'FloatingActionButton' })
|
||||
// 定义属性
|
||||
defineProps<{ property: FloatingActionButtonProperty }>()
|
||||
|
||||
// 是否展开
|
||||
const expanded = ref(false)
|
||||
// 处理展开/折叠
|
||||
const handleToggleFab = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 模态背景 */
|
||||
.modal-bg {
|
||||
position: absolute;
|
||||
left: calc(50% - 375px / 2);
|
||||
top: 0;
|
||||
z-index: 11;
|
||||
width: 375px;
|
||||
height: 100%;
|
||||
background-color: rgba(#000000, 0.4);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s;
|
||||
|
||||
&.active {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-card header="按钮配置" class="property-group" shadow="never">
|
||||
<el-form-item label="展开方向" prop="direction">
|
||||
<el-radio-group v-model="formData.direction">
|
||||
<el-radio value="vertical">垂直</el-radio>
|
||||
<el-radio value="horizontal">水平</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示文字" prop="showText">
|
||||
<el-switch v-model="formData.showText" />
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
<el-card header="按钮列表" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.list" :empty-item="{ textColor: '#fff' }">
|
||||
<template #default="{ element, index }">
|
||||
<el-form-item label="图标" :prop="`list[${index}].imgUrl`">
|
||||
<UploadImg v-model="element.imgUrl" height="56px" width="56px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文字" :prop="`list[${index}].text`">
|
||||
<InputWithColor v-model="element.text" v-model:color="element.textColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转链接" :prop="`list[${index}].url`">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FloatingActionButtonProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
// 悬浮按钮属性面板
|
||||
defineOptions({ name: 'FloatingActionButtonProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: FloatingActionButtonProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
|
||||
import { StyleValue } from 'vue'
|
||||
|
||||
// 热区的最小宽高
|
||||
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
|
||||
|
||||
// 监听鼠标移动
|
||||
document.onmousemove = (e) => {
|
||||
// 移动宽度
|
||||
const moveWidth = e.clientX - startX
|
||||
// 移动高度
|
||||
const moveHeight = e.clientY - startY
|
||||
// 移动回调
|
||||
callback(left, top, width, height, moveWidth, moveHeight)
|
||||
}
|
||||
|
||||
// 松开鼠标后,结束拖拽
|
||||
document.onmouseup = () => {
|
||||
document.onmousemove = null
|
||||
document.onmouseup = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" title="设置热区" width="780" @close="handleClose">
|
||||
<div ref="container" class="relative h-full w-750px">
|
||||
<el-image :src="imgUrl" class="pointer-events-none h-full w-750px select-none" />
|
||||
<div
|
||||
v-for="(item, hotZoneIndex) in formData"
|
||||
:key="hotZoneIndex"
|
||||
class="hot-zone"
|
||||
:style="{
|
||||
width: `${item.width}px`,
|
||||
height: `${item.height}px`,
|
||||
top: `${item.top}px`,
|
||||
left: `${item.left}px`
|
||||
}"
|
||||
@mousedown="handleMove(item, $event)"
|
||||
@dblclick="handleShowAppLinkDialog(item)"
|
||||
>
|
||||
<span class="pointer-events-none select-none">{{ item.name || '双击选择链接' }}</span>
|
||||
<Icon icon="ep:close" class="delete" :size="14" @click="handleRemove(item)" />
|
||||
|
||||
<!-- 8个控制点 -->
|
||||
<span
|
||||
class="ctrl-dot"
|
||||
v-for="(dot, dotIndex) in CONTROL_DOT_LIST"
|
||||
:key="dotIndex"
|
||||
:style="dot.style"
|
||||
@mousedown="handleResize(item, dot, $event)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleAdd" type="primary" plain>
|
||||
<Icon icon="ep:plus" class="mr-5px" />
|
||||
添加热区
|
||||
</el-button>
|
||||
<el-button @click="handleSubmit" type="primary" plain>
|
||||
<Icon icon="ep:check" class="mr-5px" />
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AppLinkSelectDialog ref="appLinkDialogRef" @app-link-change="handleAppLinkChange" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
|
||||
import { array, string } from 'vue-types'
|
||||
import {
|
||||
CONTROL_DOT_LIST,
|
||||
CONTROL_TYPE_ENUM,
|
||||
ControlDot,
|
||||
HOT_ZONE_MIN_SIZE,
|
||||
useDraggable,
|
||||
zoomIn,
|
||||
zoomOut
|
||||
} from './controller'
|
||||
import { AppLink } from '@/components/AppLinkInput/data'
|
||||
import { remove } from 'lodash-es'
|
||||
|
||||
/** 热区编辑对话框 */
|
||||
defineOptions({ name: 'HotZoneEditDialog' })
|
||||
|
||||
// 定义属性
|
||||
const props = defineProps({
|
||||
modelValue: array<HotZoneItemProperty>(),
|
||||
imgUrl: string().def('')
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = ref<HotZoneItemProperty[]>([])
|
||||
|
||||
// 弹窗的是否显示
|
||||
const dialogVisible = ref(false)
|
||||
// 打开弹窗
|
||||
const open = () => {
|
||||
// 放大
|
||||
formData.value = zoomIn(props.modelValue)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
// 提供 open 方法,用于打开弹窗
|
||||
defineExpose({ open })
|
||||
|
||||
// 热区容器
|
||||
const container = ref<HTMLDivElement>()
|
||||
|
||||
// 增加热区
|
||||
const handleAdd = () => {
|
||||
formData.value.push({
|
||||
width: HOT_ZONE_MIN_SIZE,
|
||||
height: HOT_ZONE_MIN_SIZE,
|
||||
top: 0,
|
||||
left: 0
|
||||
} as HotZoneItemProperty)
|
||||
}
|
||||
// 删除热区
|
||||
const handleRemove = (hotZone: HotZoneItemProperty) => {
|
||||
remove(formData.value, hotZone)
|
||||
}
|
||||
|
||||
// 移动热区
|
||||
const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => {
|
||||
useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => {
|
||||
setLeft(item, left + moveWidth)
|
||||
setTop(item, top + moveHeight)
|
||||
})
|
||||
}
|
||||
|
||||
// 调整热区大小、位置
|
||||
const handleResize = (item: HotZoneItemProperty, ctrlDot: ControlDot, e: MouseEvent) => {
|
||||
useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => {
|
||||
ctrlDot.types.forEach((type) => {
|
||||
switch (type) {
|
||||
case CONTROL_TYPE_ENUM.LEFT:
|
||||
setLeft(item, left + moveWidth)
|
||||
break
|
||||
case CONTROL_TYPE_ENUM.TOP:
|
||||
setTop(item, top + moveHeight)
|
||||
break
|
||||
case CONTROL_TYPE_ENUM.WIDTH:
|
||||
{
|
||||
// 上移时,高度为减少
|
||||
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT) ? -1 : 1
|
||||
setWidth(item, width + moveWidth * direction)
|
||||
}
|
||||
break
|
||||
case CONTROL_TYPE_ENUM.HEIGHT:
|
||||
{
|
||||
// 左移时,宽度为减少
|
||||
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP) ? -1 : 1
|
||||
setHeight(item, height + moveHeight * direction)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 设置X轴坐标
|
||||
const setLeft = (item: HotZoneItemProperty, left: number) => {
|
||||
// 不能超出容器
|
||||
if (left >= 0 && left <= container.value!.offsetWidth - item.width) {
|
||||
item.left = left
|
||||
}
|
||||
}
|
||||
// 设置Y轴坐标
|
||||
const setTop = (item: HotZoneItemProperty, top: number) => {
|
||||
// 不能超出容器
|
||||
if (top >= 0 && top <= container.value!.offsetHeight - item.height) {
|
||||
item.top = top
|
||||
}
|
||||
}
|
||||
// 设置宽度
|
||||
const setWidth = (item: HotZoneItemProperty, width: number) => {
|
||||
// 不能小于最小宽度 && 不能超出容器右边
|
||||
if (width >= HOT_ZONE_MIN_SIZE && item.left + width <= container.value!.offsetWidth) {
|
||||
item.width = width
|
||||
}
|
||||
}
|
||||
// 设置高度
|
||||
const setHeight = (item: HotZoneItemProperty, height: number) => {
|
||||
// 不能小于最小高度 && 不能超出容器底部
|
||||
if (height >= HOT_ZONE_MIN_SIZE && item.top + height <= container.value!.offsetHeight) {
|
||||
item.height = height
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleSubmit = () => {
|
||||
// 会自动触发handleClose
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleClose = () => {
|
||||
// 缩小
|
||||
const list = zoomOut(formData.value)
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
const activeHotZone = ref<HotZoneItemProperty>()
|
||||
const appLinkDialogRef = ref()
|
||||
const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => {
|
||||
activeHotZone.value = hotZone
|
||||
appLinkDialogRef.value.open(hotZone.url)
|
||||
}
|
||||
const handleAppLinkChange = (appLink: AppLink) => {
|
||||
if (!appLink || !activeHotZone.value) return
|
||||
activeHotZone.value.name = appLink.name
|
||||
activeHotZone.value.url = appLink.path
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hot-zone {
|
||||
position: absolute;
|
||||
background: var(--el-color-primary-light-7);
|
||||
opacity: 0.8;
|
||||
border: 1px solid var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
z-index: 10;
|
||||
|
||||
/* 控制点 */
|
||||
.ctrl-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: inherit;
|
||||
background-color: #fff;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.delete {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 2px 2px 6px 6px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 0 0 0 80%;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.delete {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/components/DiyEditor/components/mobile/HotZone/config.ts
Normal file
43
src/components/DiyEditor/components/mobile/HotZone/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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>
|
||||
42
src/components/DiyEditor/components/mobile/HotZone/index.vue
Normal file
42
src/components/DiyEditor/components/mobile/HotZone/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="relative h-full min-h-30px w-full">
|
||||
<el-image :src="property.imgUrl" class="pointer-events-none h-full w-full select-none" />
|
||||
<div
|
||||
v-for="(item, index) in 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HotZoneProperty } from './config'
|
||||
|
||||
/** 热区 */
|
||||
defineOptions({ name: 'HotZone' })
|
||||
const props = defineProps<{ property: HotZoneProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hot-zone {
|
||||
position: absolute;
|
||||
background: var(--el-color-primary-light-7);
|
||||
opacity: 0.8;
|
||||
border: 1px solid var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="80px" :model="formData" class="m-t-8px">
|
||||
<el-form-item label="上传图片" prop="imgUrl">
|
||||
<UploadImg v-model="formData.imgUrl" height="50px" width="auto" class="min-w-80px">
|
||||
<template #tip>
|
||||
<el-text type="info" size="small"> 推荐宽度 750</el-text>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-button type="primary" plain class="w-full" @click="handleOpenEditDialog">
|
||||
设置热区
|
||||
</el-button>
|
||||
</ComponentContainerProperty>
|
||||
<!-- 热区编辑对话框 -->
|
||||
<HotZoneEditDialog ref="editDialogRef" v-model="formData.list" :img-url="formData.imgUrl" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { HotZoneProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
|
||||
import HotZoneEditDialog from './components/HotZoneEditDialog/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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hot-zone {
|
||||
position: absolute;
|
||||
background: #409effbf;
|
||||
border: 1px solid var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
|
||||
/* 控制点 */
|
||||
.ctrl-dot {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<!-- 无图片 -->
|
||||
<div class="h-50px flex items-center justify-center bg-gray-3" v-if="!property.imgUrl">
|
||||
<Icon icon="ep:picture" class="text-gray-8 text-30px!" />
|
||||
</div>
|
||||
<el-image class="min-h-30px" v-else :src="property.imgUrl" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageBarProperty } from './config'
|
||||
|
||||
/** 图片展示 */
|
||||
defineOptions({ name: 'ImageBar' })
|
||||
|
||||
defineProps<{ property: ImageBarProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 图片 */
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-form-item label="上传图片" prop="imgUrl">
|
||||
<UploadImg
|
||||
v-model="formData.imgUrl"
|
||||
draggable="false"
|
||||
height="80px"
|
||||
width="100%"
|
||||
class="min-w-80px"
|
||||
>
|
||||
<template #tip> 建议宽度750 </template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" prop="url">
|
||||
<AppLinkInput v-model="formData.url" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ImageBarProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
// 图片展示属性面板
|
||||
defineOptions({ name: 'ImageBarProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: ImageBarProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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
|
||||
}
|
||||
|
||||
// 定义组件
|
||||
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>
|
||||
@@ -0,0 +1,76 @@
|
||||
<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`
|
||||
}"
|
||||
>
|
||||
<el-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`
|
||||
}"
|
||||
>
|
||||
<Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagicCubeProperty } from './config'
|
||||
|
||||
/** 广告魔方 */
|
||||
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>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="80px" :model="formData" class="m-t-8px">
|
||||
<el-text tag="p"> 魔方设置 </el-text>
|
||||
<el-text type="info" size="small"> 每格尺寸187 * 187 </el-text>
|
||||
<MagicCubeEditor
|
||||
class="m-y-16px"
|
||||
v-model="formData.list"
|
||||
:rows="4"
|
||||
:cols="4"
|
||||
@hot-area-selected="handleHotAreaSelected"
|
||||
/>
|
||||
<template v-for="(hotArea, index) in formData.list" :key="index">
|
||||
<template v-if="selectedHotAreaIndex === index">
|
||||
<el-form-item label="上传图片" :prop="`list[${index}].imgUrl`">
|
||||
<UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" :prop="`list[${index}].url`">
|
||||
<AppLinkInput v-model="hotArea.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<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-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { MagicCubeProperty } from '@/components/DiyEditor/components/mobile/MagicCube/config'
|
||||
|
||||
/** 广告魔方属性面板 */
|
||||
defineOptions({ name: 'MagicCubeProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: MagicCubeProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 选中的热区
|
||||
const selectedHotAreaIndex = ref(-1)
|
||||
const handleHotAreaSelected = (_: any, index: number) => {
|
||||
selectedHotAreaIndex.value = index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/** 宫格导航属性 */
|
||||
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: {
|
||||
// 是否显示
|
||||
show: boolean
|
||||
// 角标文字
|
||||
text: string
|
||||
// 角标文字颜色
|
||||
textColor: string
|
||||
// 角标背景颜色
|
||||
bgColor: 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>
|
||||
@@ -0,0 +1,35 @@
|
||||
<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 p-b-14px p-t-20px"
|
||||
:style="{ width: `${100 * (1 / property.column)}%` }"
|
||||
>
|
||||
<!-- 右上角角标 -->
|
||||
<span
|
||||
v-if="item.badge?.show"
|
||||
class="absolute left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-center text-12px leading-20px"
|
||||
:style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
|
||||
>
|
||||
{{ item.badge.text }}
|
||||
</span>
|
||||
<el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" />
|
||||
<span class="m-t-8px h-16px text-12px leading-16px" :style="{ color: item.titleColor }">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="m-t-6px h-12px text-10px leading-12px" :style="{ color: item.subtitleColor }">
|
||||
{{ item.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MenuGridProperty } from './config'
|
||||
/** 宫格导航 */
|
||||
defineOptions({ name: 'MenuGrid' })
|
||||
defineProps<{ property: MenuGridProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="80px" :model="formData" class="m-t-8px">
|
||||
<el-form-item label="每行数量" prop="column">
|
||||
<el-radio-group v-model="formData.column">
|
||||
<el-radio :value="3">3个</el-radio>
|
||||
<el-radio :value="4">4个</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-card header="菜单设置" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.list" :empty-item="EMPTY_MENU_GRID_ITEM_PROPERTY">
|
||||
<template #default="{ element }">
|
||||
<el-form-item label="图标" prop="iconUrl">
|
||||
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
|
||||
<template #tip> 建议尺寸:44 * 44 </template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题" prop="subtitle">
|
||||
<InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" prop="url">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示角标" prop="badge.show">
|
||||
<el-switch v-model="element.badge.show" />
|
||||
</el-form-item>
|
||||
<template v-if="element.badge.show">
|
||||
<el-form-item label="角标内容" prop="badge.text">
|
||||
<InputWithColor
|
||||
v-model="element.badge.text"
|
||||
v-model:color="element.badge.textColor"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="背景颜色" prop="badge.bgColor">
|
||||
<ColorInput v-model="element.badge.bgColor" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import {
|
||||
EMPTY_MENU_GRID_ITEM_PROPERTY,
|
||||
MenuGridProperty
|
||||
} from '@/components/DiyEditor/components/mobile/MenuGrid/config'
|
||||
|
||||
/** 宫格导航属性面板 */
|
||||
defineOptions({ name: 'MenuGridProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: MenuGridProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/** 列表导航属性 */
|
||||
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>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="min-h-42px flex flex-col">
|
||||
<div
|
||||
v-for="(item, index) in property.list"
|
||||
:key="index"
|
||||
class="item h-42px flex flex-row items-center justify-between gap-4px p-x-12px"
|
||||
>
|
||||
<div class="flex flex-1 flex-row items-center gap-8px">
|
||||
<el-image v-if="item.iconUrl" class="h-16px w-16px" :src="item.iconUrl" />
|
||||
<span class="text-16px" :style="{ color: item.titleColor }">{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="item-center flex flex-row justify-center gap-4px">
|
||||
<span class="text-12px" :style="{ color: item.subtitleColor }">{{ item.subtitle }}</span>
|
||||
<Icon icon="ep-arrow-right" color="#000" :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MenuListProperty } from './config'
|
||||
/** 列表导航 */
|
||||
defineOptions({ name: 'MenuList' })
|
||||
defineProps<{ property: MenuListProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.item + .item {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-text tag="p"> 菜单设置 </el-text>
|
||||
<el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
|
||||
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="60px" :model="formData" class="m-t-8px">
|
||||
<Draggable v-model="formData.list" :empty-item="EMPTY_MENU_LIST_ITEM_PROPERTY">
|
||||
<template #default="{ element }">
|
||||
<el-form-item label="图标" prop="iconUrl">
|
||||
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
|
||||
<template #tip> 建议尺寸:44 * 44 </template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题" prop="subtitle">
|
||||
<InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" prop="url">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import {
|
||||
EMPTY_MENU_LIST_ITEM_PROPERTY,
|
||||
MenuListProperty
|
||||
} from '@/components/DiyEditor/components/mobile/MenuList/config'
|
||||
|
||||
/** 列表导航属性面板 */
|
||||
defineOptions({ name: 'MenuListProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: MenuListProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/** 菜单导航属性 */
|
||||
export interface MenuSwiperProperty {
|
||||
// 布局: 图标+文字 | 图标
|
||||
layout: 'iconText' | 'icon'
|
||||
// 行数
|
||||
row: number
|
||||
// 列数
|
||||
column: number
|
||||
// 导航菜单列表
|
||||
list: MenuSwiperItemProperty[]
|
||||
// 组件样式
|
||||
style: ComponentStyle
|
||||
}
|
||||
/** 菜单导航项目属性 */
|
||||
export interface MenuSwiperItemProperty {
|
||||
// 图标链接
|
||||
iconUrl: string
|
||||
// 标题
|
||||
title: string
|
||||
// 标题颜色
|
||||
titleColor: string
|
||||
// 链接
|
||||
url: string
|
||||
// 角标
|
||||
badge: {
|
||||
// 是否显示
|
||||
show: boolean
|
||||
// 角标文字
|
||||
text: string
|
||||
// 角标文字颜色
|
||||
textColor: string
|
||||
// 角标背景颜色
|
||||
bgColor: 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>
|
||||
119
src/components/DiyEditor/components/mobile/MenuSwiper/index.vue
Normal file
119
src/components/DiyEditor/components/mobile/MenuSwiper/index.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<el-carousel
|
||||
:height="`${carouselHeight}px`"
|
||||
:autoplay="false"
|
||||
arrow="hover"
|
||||
indicator-position="outside"
|
||||
>
|
||||
<el-carousel-item 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--10px top--10px z-1 h-20px rounded-10px p-x-6px text-center text-12px leading-20px"
|
||||
:style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
|
||||
>
|
||||
{{ item.badge.text }}
|
||||
</span>
|
||||
<el-image v-if="item.iconUrl" :src="item.iconUrl" class="h-full w-full" />
|
||||
</div>
|
||||
<!-- 标题 -->
|
||||
<span
|
||||
v-if="property.layout === 'iconText'"
|
||||
class="text-12px"
|
||||
:style="{
|
||||
color: item.titleColor,
|
||||
height: `${TITLE_HEIGHT}px`,
|
||||
lineHeight: `${TITLE_HEIGHT}px`
|
||||
}"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MenuSwiperProperty, MenuSwiperItemProperty } from './config'
|
||||
/** 菜单导航 */
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
// 重写指示器样式,与 APP 保持一致
|
||||
:root {
|
||||
.el-carousel__indicator {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.el-carousel__button {
|
||||
--el-carousel-indicator-height: 6px;
|
||||
--el-carousel-indicator-width: 6px;
|
||||
--el-carousel-indicator-out-color: #ff6000;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
.el-carousel__indicator.is-active {
|
||||
.el-carousel__button {
|
||||
--el-carousel-indicator-width: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="80px" :model="formData" class="m-t-8px">
|
||||
<el-form-item label="布局" prop="layout">
|
||||
<el-radio-group v-model="formData.layout">
|
||||
<el-radio value="iconText">图标+文字</el-radio>
|
||||
<el-radio value="icon">仅图标</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="行数" prop="row">
|
||||
<el-radio-group v-model="formData.row">
|
||||
<el-radio :value="1">1行</el-radio>
|
||||
<el-radio :value="2">2行</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="列数" prop="column">
|
||||
<el-radio-group v-model="formData.column">
|
||||
<el-radio :value="3">3列</el-radio>
|
||||
<el-radio :value="4">4列</el-radio>
|
||||
<el-radio :value="5">5列</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-card header="菜单设置" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.list" :empty-item="cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)">
|
||||
<template #default="{ element }">
|
||||
<el-form-item label="图标" prop="iconUrl">
|
||||
<UploadImg v-model="element.iconUrl" height="80px" width="80px">
|
||||
<template #tip> 建议尺寸:98 * 98 </template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<InputWithColor v-model="element.title" v-model:color="element.titleColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" prop="url">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示角标" prop="badge.show">
|
||||
<el-switch v-model="element.badge.show" />
|
||||
</el-form-item>
|
||||
<template v-if="element.badge.show">
|
||||
<el-form-item label="角标内容" prop="badge.text">
|
||||
<InputWithColor
|
||||
v-model="element.badge.text"
|
||||
v-model:color="element.badge.textColor"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="背景颜色" prop="badge.bgColor">
|
||||
<ColorInput v-model="element.badge.bgColor" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import {
|
||||
EMPTY_MENU_SWIPER_ITEM_PROPERTY,
|
||||
MenuSwiperProperty
|
||||
} from '@/components/DiyEditor/components/mobile/MenuSwiper/config'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/** 菜单导航属性面板 */
|
||||
defineOptions({ name: 'MenuSwiperProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: MenuSwiperProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="h-40px flex items-center justify-center">
|
||||
<MagicCubeEditor
|
||||
v-model="cellList"
|
||||
:cols="cellCount"
|
||||
:cube-size="38"
|
||||
:rows="1"
|
||||
class="m-b-16px"
|
||||
@hot-area-selected="handleHotAreaSelected"
|
||||
/>
|
||||
<img v-if="isMp" alt="" class="h-30px w-76px" src="@/assets/imgs/diy/app-nav-bar-mp.png" />
|
||||
</div>
|
||||
<template v-for="(cell, cellIndex) in cellList" :key="cellIndex">
|
||||
<template v-if="selectedHotAreaIndex === cellIndex">
|
||||
<el-form-item :prop="`cell[${cellIndex}].type`" label="类型">
|
||||
<el-radio-group v-model="cell.type">
|
||||
<el-radio value="text">文字</el-radio>
|
||||
<el-radio value="image">图片</el-radio>
|
||||
<el-radio value="search">搜索框</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<!-- 1. 文字 -->
|
||||
<template v-if="cell.type === 'text'">
|
||||
<el-form-item :prop="`cell[${cellIndex}].text`" label="内容">
|
||||
<el-input v-model="cell!.text" maxlength="10" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item :prop="`cell[${cellIndex}].text`" label="颜色">
|
||||
<ColorInput v-model="cell!.textColor" />
|
||||
</el-form-item>
|
||||
<el-form-item :prop="`cell[${cellIndex}].url`" label="链接">
|
||||
<AppLinkInput v-model="cell.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<!-- 2. 图片 -->
|
||||
<template v-else-if="cell.type === 'image'">
|
||||
<el-form-item :prop="`cell[${cellIndex}].imgUrl`" label="图片">
|
||||
<UploadImg v-model="cell.imgUrl" :limit="1" height="56px" width="56px">
|
||||
<template #tip>建议尺寸 56*56</template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item :prop="`cell[${cellIndex}].url`" label="链接">
|
||||
<AppLinkInput v-model="cell.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<!-- 3. 搜索框 -->
|
||||
<template v-else>
|
||||
<el-form-item :prop="`cell[${cellIndex}].placeholder`" label="提示文字">
|
||||
<el-input v-model="cell.placeholder" maxlength="10" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item :prop="`cell[${cellIndex}].borderRadius`" label="圆角">
|
||||
<el-slider
|
||||
v-model="cell.borderRadius"
|
||||
:max="100"
|
||||
:min="0"
|
||||
:show-input-controls="false"
|
||||
input-size="small"
|
||||
show-input
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NavigationBarCellProperty } from '../config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
// 导航栏属性面板
|
||||
defineOptions({ name: 'NavigationBarCellProperty' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: NavigationBarCellProperty[]
|
||||
isMp: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
isMp: true
|
||||
}
|
||||
)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const cellList = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 单元格数量:小程序6个(右侧胶囊按钮占了2个),其它平台8个
|
||||
const cellCount = computed(() => (props.isMp ? 6 : 8))
|
||||
|
||||
// 选中的热区
|
||||
const selectedHotAreaIndex = ref(0)
|
||||
const handleHotAreaSelected = (cellValue: NavigationBarCellProperty, index: number) => {
|
||||
selectedHotAreaIndex.value = index
|
||||
if (!cellValue.type) {
|
||||
cellValue.type = 'text'
|
||||
cellValue.textColor = '#111111'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 顶部导航栏属性 */
|
||||
export interface NavigationBarProperty {
|
||||
// 背景类型
|
||||
bgType: 'color' | 'img'
|
||||
// 背景颜色
|
||||
bgColor: string
|
||||
// 图片链接
|
||||
bgImg: string
|
||||
// 样式类型:默认 | 沉浸式
|
||||
styleType: 'normal' | 'inner'
|
||||
// 常驻显示
|
||||
alwaysShow: boolean
|
||||
// 小程序单元格列表
|
||||
mpCells: NavigationBarCellProperty[]
|
||||
// 其它平台单元格列表
|
||||
otherCells: NavigationBarCellProperty[]
|
||||
// 本地变量
|
||||
_local: {
|
||||
// 预览顶部导航(小程序)
|
||||
previewMp: boolean
|
||||
// 预览顶部导航(非小程序)
|
||||
previewOther: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部导航栏 - 单元格 属性 */
|
||||
export interface NavigationBarCellProperty {
|
||||
// 类型:文字 | 图片 | 搜索框
|
||||
type: 'text' | 'image' | 'search'
|
||||
// 宽度
|
||||
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>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="navigation-bar" :style="bgStyle">
|
||||
<div class="h-full w-full flex 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="@/assets/imgs/diy/app-nav-bar-mp.png"
|
||||
alt=""
|
||||
class="h-30px w-86px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { NavigationBarCellProperty, NavigationBarProperty } from './config'
|
||||
import SearchBar from '@/components/DiyEditor/components/mobile/SearchBar/index.vue'
|
||||
import { StyleValue } from 'vue'
|
||||
import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
|
||||
|
||||
/** 页面顶部导航栏 */
|
||||
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>
|
||||
<style lang="scss" scoped>
|
||||
.navigation-bar {
|
||||
display: flex;
|
||||
height: 50px;
|
||||
background: #fff;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
|
||||
/* 左边 */
|
||||
.left {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.center {
|
||||
font-size: 14px;
|
||||
line-height: 35px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 右边 */
|
||||
.right {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<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" />
|
||||
<span class="text-xs text-gray-400 ml-2 mb-2">建议宽度: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="m-b-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="m-b-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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NavigationBarProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import NavigationBarCellProperty from '@/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue'
|
||||
// 导航栏属性面板
|
||||
defineOptions({ name: 'NavigationBarProperty' })
|
||||
// 表单校验
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: NavigationBarProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
if (!formData.value._local) {
|
||||
formData.value._local = { previewMp: true, previewOther: false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center p-y-4px text-12px"
|
||||
:style="{ backgroundColor: property.backgroundColor, color: property.textColor }"
|
||||
>
|
||||
<el-image :src="property.iconUrl" class="h-18px" />
|
||||
<el-divider direction="vertical" />
|
||||
<el-carousel height="24px" direction="vertical" :autoplay="true" class="flex-1 p-r-8px">
|
||||
<el-carousel-item v-for="(item, index) in property.contents" :key="index">
|
||||
<div class="h-24px truncate leading-24px">{{ item.text }}</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
<Icon icon="ep:arrow-right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NoticeBarProperty } from './config'
|
||||
|
||||
/** 公告栏 */
|
||||
defineOptions({ name: 'NoticeBar' })
|
||||
|
||||
defineProps<{ property: NoticeBarProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="80px" :model="formData" :rules="rules">
|
||||
<el-form-item label="公告图标" prop="iconUrl">
|
||||
<UploadImg v-model="formData.iconUrl" height="48px">
|
||||
<template #tip>建议尺寸:24 * 24</template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
<el-form-item label="背景颜色" prop="backgroundColor">
|
||||
<ColorInput v-model="formData.backgroundColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文字颜色" prop="文字颜色">
|
||||
<ColorInput v-model="formData.textColor" />
|
||||
</el-form-item>
|
||||
<el-card header="公告内容" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.contents">
|
||||
<template #default="{ element }">
|
||||
<el-form-item label="公告" prop="text" label-width="40px">
|
||||
<el-input v-model="element.text" placeholder="请输入公告" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" prop="url" label-width="40px">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NoticeBarProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
// 通知栏属性面板
|
||||
defineOptions({ name: 'NoticeBarProperty' })
|
||||
// 表单校验
|
||||
const rules = {
|
||||
content: [{ required: true, message: '请输入公告', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: NoticeBarProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DiyComponent } from '@/components/DiyEditor/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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<el-form label-width="80px" :model="formData" :rules="rules">
|
||||
<el-form-item label="页面描述" prop="description">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="formData!.description"
|
||||
placeholder="用户通过微信分享给朋友时,会自动显示页面描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="背景颜色" prop="backgroundColor">
|
||||
<ColorInput v-model="formData!.backgroundColor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景图片" prop="backgroundImage">
|
||||
<UploadImg v-model="formData!.backgroundImage" :limit="1">
|
||||
<template #tip>建议宽度 750px</template>
|
||||
</UploadImg>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PageConfigProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
// 导航栏属性面板
|
||||
defineOptions({ name: 'PageConfigProperty' })
|
||||
// 表单校验
|
||||
const rules = {}
|
||||
|
||||
const props = defineProps<{ modelValue: PageConfigProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
26
src/components/DiyEditor/components/mobile/Popover/config.ts
Normal file
26
src/components/DiyEditor/components/mobile/Popover/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 弹窗广告属性 */
|
||||
export interface PopoverProperty {
|
||||
list: PopoverItemProperty[]
|
||||
}
|
||||
|
||||
export interface PopoverItemProperty {
|
||||
// 图片地址
|
||||
imgUrl: string
|
||||
// 跳转连接
|
||||
url: string
|
||||
// 显示类型:仅显示一次、每次启动都会显示
|
||||
showType: 'once' | 'always'
|
||||
}
|
||||
|
||||
// 定义组件
|
||||
export const component = {
|
||||
id: 'Popover',
|
||||
name: '弹窗广告',
|
||||
icon: 'carbon:popup',
|
||||
position: 'fixed',
|
||||
property: {
|
||||
list: [{ showType: 'once' }]
|
||||
}
|
||||
} as DiyComponent<PopoverProperty>
|
||||
38
src/components/DiyEditor/components/mobile/Popover/index.vue
Normal file
38
src/components/DiyEditor/components/mobile/Popover/index.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="(item, index) in property.list"
|
||||
:key="index"
|
||||
class="absolute bottom-50% right-50% h-454px w-292px border-1px border-gray border-rounded-4px border-solid bg-white p-1px"
|
||||
:style="{
|
||||
zIndex: 100 + index + (activeIndex === index ? 100 : 0),
|
||||
marginRight: `${-146 - index * 20}px`,
|
||||
marginBottom: `${-227 - index * 20}px`
|
||||
}"
|
||||
@click="handleActive(index)"
|
||||
>
|
||||
<el-image :src="item.imgUrl" fit="contain" class="h-full w-full">
|
||||
<template #error>
|
||||
<div class="h-full w-full flex items-center justify-center">
|
||||
<Icon icon="ep:picture" />
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="absolute right-1 top-1 text-12px">{{ index + 1 }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PopoverProperty } from './config'
|
||||
|
||||
/** 弹窗广告 */
|
||||
defineOptions({ name: 'Popover' })
|
||||
// 定义属性
|
||||
defineProps<{ property: PopoverProperty }>()
|
||||
|
||||
// 处理选中
|
||||
const activeIndex = ref(0)
|
||||
const handleActive = (index: number) => {
|
||||
activeIndex.value = index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<Draggable v-model="formData.list" :empty-item="{ showType: 'once' }">
|
||||
<template #default="{ element, index }">
|
||||
<el-form-item label="图片" :prop="`list[${index}].imgUrl`">
|
||||
<UploadImg v-model="element.imgUrl" height="56px" width="56px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转链接" :prop="`list[${index}].url`">
|
||||
<AppLinkInput v-model="element.url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示次数" :prop="`list[${index}].showType`">
|
||||
<el-radio-group v-model="element.showType">
|
||||
<el-tooltip content="只显示一次,下次打开时不显示" placement="bottom">
|
||||
<el-radio value="once">一次</el-radio>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="每次打开时都会显示" placement="bottom">
|
||||
<el-radio value="always">不限</el-radio>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopoverProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
// 弹窗广告属性面板
|
||||
defineOptions({ name: 'PopoverProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: PopoverProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -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: '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>
|
||||
170
src/components/DiyEditor/components/mobile/ProductCard/index.vue
Normal file
170
src/components/DiyEditor/components/mobile/ProductCard/index.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<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 && property.badge.imgUrl"
|
||||
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 }"
|
||||
>
|
||||
¥{{ 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 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'
|
||||
import { fenToYuan } from '../../../../../utils'
|
||||
|
||||
/** 商品卡片 */
|
||||
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>
|
||||
@@ -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 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-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 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 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 { useVModel } from '@vueuse/core'
|
||||
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 = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 商品栏属性 */
|
||||
export interface ProductListProperty {
|
||||
// 布局类型:双列 | 三列 | 水平滑动
|
||||
layoutType: 'twoCol' | 'threeCol' | 'horizSwiper'
|
||||
// 商品字段
|
||||
fields: {
|
||||
// 商品名称
|
||||
name: ProductListFieldProperty
|
||||
// 商品价格
|
||||
price: ProductListFieldProperty
|
||||
}
|
||||
// 角标
|
||||
badge: {
|
||||
// 是否显示
|
||||
show: boolean
|
||||
// 角标图片
|
||||
imgUrl: string
|
||||
}
|
||||
// 上圆角
|
||||
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>
|
||||
132
src/components/DiyEditor/components/mobile/ProductList/index.vue
Normal file
132
src/components/DiyEditor/components/mobile/ProductList/index.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<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
|
||||
}"
|
||||
>
|
||||
<!-- 商品 -->
|
||||
<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-1 items-center justify-center"
|
||||
>
|
||||
<el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
|
||||
</div>
|
||||
<!-- 商品封面图 -->
|
||||
<el-image fit="cover" :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" />
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-col gap-8px p-8px box-border',
|
||||
{
|
||||
'w-[calc(100%-64px)]': columns === 2,
|
||||
'w-full': columns === 3
|
||||
}
|
||||
]"
|
||||
>
|
||||
<!-- 商品名称 -->
|
||||
<div
|
||||
v-if="property.fields.name.show"
|
||||
class="truncate text-12px"
|
||||
:style="{ color: property.fields.name.color }"
|
||||
>
|
||||
{{ spu.name }}
|
||||
</div>
|
||||
<div>
|
||||
<!-- 商品价格 -->
|
||||
<span
|
||||
v-if="property.fields.price.show"
|
||||
class="text-12px"
|
||||
:style="{ color: property.fields.price.color }"
|
||||
>
|
||||
¥{{ fenToYuan(spu.price) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ProductListProperty } from './config'
|
||||
import * as ProductSpuApi from '@/api/mall/product/spu'
|
||||
import { fenToYuan } from '@/utils'
|
||||
|
||||
/** 商品栏 */
|
||||
defineOptions({ name: 'ProductList' })
|
||||
// 定义属性
|
||||
const props = defineProps<{ property: ProductListProperty }>()
|
||||
// 商品列表
|
||||
const spuList = ref<ProductSpuApi.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>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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 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-tooltip class="item" content="水平滑动" placement="bottom">
|
||||
<el-radio-button value="horizSwiper">
|
||||
<Icon icon="system-uicons:carousel" />
|
||||
</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.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-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="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 { ProductListProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
|
||||
|
||||
// 商品栏属性面板
|
||||
defineOptions({ name: 'ProductListProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: ProductListProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="min-h-30px" v-html="article?.content"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PromotionArticleProperty } from './config'
|
||||
import * as ArticleApi from '@/api/mall/promotion/article/index'
|
||||
|
||||
/** 营销文章 */
|
||||
defineOptions({ name: 'PromotionArticle' })
|
||||
// 定义属性
|
||||
const props = defineProps<{ property: PromotionArticleProperty }>()
|
||||
// 商品列表
|
||||
const article = ref<ArticleApi.ArticleVO>()
|
||||
watch(
|
||||
() => props.property.id,
|
||||
async () => {
|
||||
if (props.property.id) {
|
||||
article.value = await ArticleApi.getArticle(props.property.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="40px" :model="formData">
|
||||
<el-form-item label="文章" prop="id">
|
||||
<el-select
|
||||
v-model="formData.id"
|
||||
placeholder="请选择文章"
|
||||
class="w-full"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="queryArticleList"
|
||||
:loading="loading"
|
||||
>
|
||||
<el-option
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
:label="article.title"
|
||||
:value="article.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PromotionArticleProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import * as ArticleApi from '@/api/mall/promotion/article/index'
|
||||
|
||||
// 营销文章属性面板
|
||||
defineOptions({ name: 'PromotionArticleProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: PromotionArticleProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
// 文章列表
|
||||
const articles = ref<ArticleApi.ArticleVO>([])
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
// 查询文章列表
|
||||
const queryArticleList = async (title?: string) => {
|
||||
loading.value = true
|
||||
const { list } = await ArticleApi.getArticlePage({ title, pageSize: 10 })
|
||||
articles.value = list
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
queryArticleList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 拼团属性 */
|
||||
export interface PromotionCombinationProperty {
|
||||
// 布局类型:单列 | 三列
|
||||
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
|
||||
// 商品字段
|
||||
fields: {
|
||||
// 商品名称
|
||||
name: PromotionCombinationFieldProperty
|
||||
// 商品简介
|
||||
introduction: PromotionCombinationFieldProperty
|
||||
// 商品价格
|
||||
price: PromotionCombinationFieldProperty
|
||||
// 市场价
|
||||
marketPrice: PromotionCombinationFieldProperty
|
||||
// 商品销量
|
||||
salesCount: PromotionCombinationFieldProperty
|
||||
// 商品库存
|
||||
stock: PromotionCombinationFieldProperty
|
||||
}
|
||||
// 角标
|
||||
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 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>
|
||||
@@ -0,0 +1,201 @@
|
||||
<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 }"
|
||||
>
|
||||
¥{{ 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 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 { PromotionCombinationProperty } from './config'
|
||||
import * as ProductSpuApi from '@/api/mall/product/spu'
|
||||
import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
|
||||
import { fenToYuan } from '@/utils'
|
||||
|
||||
/** 拼团卡片 */
|
||||
defineOptions({ name: 'PromotionCombination' })
|
||||
// 定义属性
|
||||
const props = defineProps<{ property: PromotionCombinationProperty }>()
|
||||
// 商品列表
|
||||
const spuList = ref<ProductSpuApi.Spu[]>([])
|
||||
const spuIdList = ref<number[]>([])
|
||||
const combinationActivityList = ref<CombinationActivityApi.CombinationActivityVO[]>([])
|
||||
|
||||
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>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-card header="拼团活动" class="property-group" shadow="never">
|
||||
<CombinationShowcase v-model="formData.activityIds" />
|
||||
</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 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 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 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 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 { PromotionCombinationProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import CombinationShowcase from '@/views/mall/promotion/combination/components/CombinationShowcase.vue'
|
||||
|
||||
// 拼团属性面板
|
||||
defineOptions({ name: 'PromotionCombinationProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: PromotionCombinationProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
// 活动列表
|
||||
const activityList = ref<CombinationActivityApi.CombinationActivityVO[]>([])
|
||||
onMounted(async () => {
|
||||
const { list } = await CombinationActivityApi.getCombinationActivityPage({
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
activityList.value = list
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 { useVModel } from '@vueuse/core'
|
||||
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 = useVModel(props, 'modelValue', emit)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
|
||||
|
||||
/** 秒杀属性 */
|
||||
export interface PromotionSeckillProperty {
|
||||
// 布局类型:单列 | 三列
|
||||
layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
|
||||
// 商品字段
|
||||
fields: {
|
||||
// 商品名称
|
||||
name: PromotionSeckillFieldProperty
|
||||
// 商品简介
|
||||
introduction: PromotionSeckillFieldProperty
|
||||
// 商品价格
|
||||
price: PromotionSeckillFieldProperty
|
||||
// 市场价
|
||||
marketPrice: PromotionSeckillFieldProperty
|
||||
// 商品销量
|
||||
salesCount: PromotionSeckillFieldProperty
|
||||
// 商品库存
|
||||
stock: PromotionSeckillFieldProperty
|
||||
}
|
||||
// 角标
|
||||
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 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>
|
||||
@@ -0,0 +1,201 @@
|
||||
<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 }"
|
||||
>
|
||||
¥{{ 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 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 { PromotionSeckillProperty } from './config'
|
||||
import * as ProductSpuApi from '@/api/mall/product/spu'
|
||||
import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
|
||||
import { fenToYuan } from '@/utils'
|
||||
|
||||
/** 秒杀卡片 */
|
||||
defineOptions({ name: 'PromotionSeckill' })
|
||||
// 定义属性
|
||||
const props = defineProps<{ property: PromotionSeckillProperty }>()
|
||||
// 商品列表
|
||||
const spuList = ref<ProductSpuApi.Spu[]>([])
|
||||
const spuIdList = ref<number[]>([])
|
||||
const seckillActivityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
|
||||
|
||||
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>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<el-form label-width="80px" :model="formData">
|
||||
<el-card header="秒杀活动" class="property-group" shadow="never">
|
||||
<SeckillShowcase v-model="formData.activityIds" />
|
||||
</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 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 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 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 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 { PromotionSeckillProperty } from './config'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import SeckillShowcase from '@/views/mall/promotion/seckill/components/SeckillShowcase.vue'
|
||||
|
||||
// 秒杀属性面板
|
||||
defineOptions({ name: 'PromotionSeckillProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: PromotionSeckillProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
// 活动列表
|
||||
const activityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
|
||||
onMounted(async () => {
|
||||
const { list } = await SeckillActivityApi.getSeckillActivityPage({
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
activityList.value = list
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/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 = 'left' | 'center'
|
||||
|
||||
// 定义组件
|
||||
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>
|
||||
@@ -0,0 +1,75 @@
|
||||
<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
|
||||
}"
|
||||
>
|
||||
<Icon icon="ep:search" />
|
||||
<span>{{ property.placeholder || '搜索商品' }}</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<!-- 搜索热词 -->
|
||||
<span v-for="(keyword, index) in property.hotKeywords" :key="index">{{ keyword }}</span>
|
||||
<!-- 扫一扫 -->
|
||||
<Icon icon="ant-design:scan-outlined" v-show="property.showScan" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchProperty } from './config'
|
||||
/** 搜索框 */
|
||||
defineOptions({ name: 'SearchBar' })
|
||||
defineProps<{ property: SearchProperty }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
/* 搜索框 */
|
||||
.inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
font-size: 14px;
|
||||
align-items: center;
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.right {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<ComponentContainerProperty v-model="formData.style">
|
||||
<!-- 表单 -->
|
||||
<el-form label-width="80px" :model="formData" class="m-t-8px">
|
||||
<el-card header="搜索热词" class="property-group" shadow="never">
|
||||
<Draggable v-model="formData.hotKeywords" :empty-item="''" :min="0">
|
||||
<template #default="{ index }">
|
||||
<el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</el-card>
|
||||
<el-card header="搜索样式" class="property-group" shadow="never">
|
||||
<el-form-item label="框体样式">
|
||||
<el-radio-group v-model="formData!.borderRadius">
|
||||
<el-tooltip content="方形" placement="top">
|
||||
<el-radio-button :value="0">
|
||||
<Icon icon="tabler:input-search" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="圆形" placement="top">
|
||||
<el-radio-button :value="10">
|
||||
<Icon icon="iconoir:input-search" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示文字" prop="placeholder">
|
||||
<el-input v-model="formData.placeholder" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文本位置" prop="placeholderPosition">
|
||||
<el-radio-group v-model="formData!.placeholderPosition">
|
||||
<el-tooltip content="居左" placement="top">
|
||||
<el-radio-button value="left">
|
||||
<Icon icon="ant-design:align-left-outlined" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="居中" placement="top">
|
||||
<el-radio-button value="center">
|
||||
<Icon icon="ant-design:align-center-outlined" />
|
||||
</el-radio-button>
|
||||
</el-tooltip>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="扫一扫" prop="showScan">
|
||||
<el-switch v-model="formData!.showScan" />
|
||||
</el-form-item>
|
||||
<el-form-item label="框体高度" prop="height">
|
||||
<el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
|
||||
</el-form-item>
|
||||
<el-form-item label="框体颜色" prop="backgroundColor">
|
||||
<ColorInput v-model="formData.backgroundColor" />
|
||||
</el-form-item>
|
||||
<el-form-item class="lef" label="文本颜色" prop="textColor">
|
||||
<ColorInput v-model="formData.textColor" />
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</ComponentContainerProperty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
|
||||
import { isString } from '@/utils/is'
|
||||
|
||||
/** 搜索框属性面板 */
|
||||
defineOptions({ name: 'SearchProperty' })
|
||||
|
||||
const props = defineProps<{ modelValue: SearchProperty }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const formData = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 监听热词数组变化
|
||||
watch(
|
||||
() => formData.value.hotKeywords,
|
||||
(newVal) => {
|
||||
// 找到非字符串项的索引
|
||||
const nonStringIndex = newVal.findIndex((item) => !isString(item))
|
||||
if (nonStringIndex !== -1) {
|
||||
formData.value.hotKeywords[nonStringIndex] = ''
|
||||
}
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user