diff --git a/src/api/mall/promotion/diy/page.ts b/src/api/mall/promotion/diy/page.ts new file mode 100644 index 00000000..255015d2 --- /dev/null +++ b/src/api/mall/promotion/diy/page.ts @@ -0,0 +1,35 @@ +import request from '@/config/axios' + +export interface DiyPageVO { + id?: number + templateId?: number + name: string + remark: string + previewImageUrls: string[] + property: string +} + +// 查询装修页面列表 +export const getDiyPagePage = async (params: any) => { + return await request.get({ url: `/promotion/diy-page/page`, params }) +} + +// 查询装修页面详情 +export const getDiyPage = async (id: number) => { + return await request.get({ url: `/promotion/diy-page/get?id=` + id }) +} + +// 新增装修页面 +export const createDiyPage = async (data: DiyPageVO) => { + return await request.post({ url: `/promotion/diy-page/create`, data }) +} + +// 修改装修页面 +export const updateDiyPage = async (data: DiyPageVO) => { + return await request.put({ url: `/promotion/diy-page/update`, data }) +} + +// 删除装修页面 +export const deleteDiyPage = async (id: number) => { + return await request.delete({ url: `/promotion/diy-page/delete?id=` + id }) +} diff --git a/src/api/mall/promotion/diy/template.ts b/src/api/mall/promotion/diy/template.ts new file mode 100644 index 00000000..72eea41c --- /dev/null +++ b/src/api/mall/promotion/diy/template.ts @@ -0,0 +1,41 @@ +import request from '@/config/axios' + +export interface DiyTemplateVO { + id?: number + name: string + used: boolean + usedTime?: Date + remark: string + previewImageUrls: string[] + property: string +} + +// 查询装修模板列表 +export const getDiyTemplatePage = async (params: any) => { + return await request.get({ url: `/promotion/diy-template/page`, params }) +} + +// 查询装修模板详情 +export const getDiyTemplate = async (id: number) => { + return await request.get({ url: `/promotion/diy-template/get?id=` + id }) +} + +// 新增装修模板 +export const createDiyTemplate = async (data: DiyTemplateVO) => { + return await request.post({ url: `/promotion/diy-template/create`, data }) +} + +// 修改装修模板 +export const updateDiyTemplate = async (data: DiyTemplateVO) => { + return await request.put({ url: `/promotion/diy-template/update`, data }) +} + +// 删除装修模板 +export const deleteDiyTemplate = async (id: number) => { + return await request.delete({ url: `/promotion/diy-template/delete?id=` + id }) +} + +// 使用装修模板 +export const useDiyTemplate = async (id: number) => { + return await request.put({ url: `/promotion/diy-template/use?id=` + id }) +} diff --git a/src/assets/imgs/diy/statusBar.png b/src/assets/imgs/diy/statusBar.png new file mode 100644 index 00000000..b85562e4 Binary files /dev/null and b/src/assets/imgs/diy/statusBar.png differ diff --git a/src/components/ColorInput/index.vue b/src/components/ColorInput/index.vue new file mode 100644 index 00000000..abd083a1 --- /dev/null +++ b/src/components/ColorInput/index.vue @@ -0,0 +1,54 @@ +<template> + <el-input v-model="color"> + <template #prepend> + <el-color-picker v-model="color" :predefine="COLORS" /> + </template> + </el-input> +</template> + +<script setup lang="ts"> +import { propTypes } from '@/utils/propTypes' + +// 颜色输入框 +defineOptions({ name: 'ColorInput' }) + +// 预设颜色 +const COLORS = [ + '#ff4500', + '#ff8c00', + '#ffd700', + '#90ee90', + '#00ced1', + '#1e90ff', + '#c71585', + '#409EFF', + '#909399', + '#C0C4CC', + '#b7390b', + '#ff7800', + '#fad400', + '#5b8c5f', + '#00babd', + '#1f73c3', + '#711f57' +] + +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> diff --git a/src/components/DiyEditor/components/ComponentLibrary.vue b/src/components/DiyEditor/components/ComponentLibrary.vue new file mode 100644 index 00000000..8e918fa9 --- /dev/null +++ b/src/components/DiyEditor/components/ComponentLibrary.vue @@ -0,0 +1,196 @@ +<template> + <el-aside class="editor-left" width="260px"> + <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" + :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[]>([]) +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>) => { + return cloneDeep(component) +} +</script> + +<style scoped lang="scss"> +.editor-left { + z-index: 1; + flex-shrink: 0; + box-shadow: 8px 0 8px -8px rgba(0, 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) { + border-bottom: none; + background-color: var(--el-bg-color-page); + padding: 0 24px; + height: 32px; + line-height: 32px; + } + + .component-container { + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .component { + width: 86px; + height: 86px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-right: 1px solid var(--el-border-color-lighter); + border-bottom: 1px solid var(--el-border-color-lighter); + cursor: move; + + .el-icon { + margin-bottom: 4px; + color: gray; + } + } + .component.active, + .component:hover { + background: var(--el-color-primary); + color: var(--el-color-white); + + .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 { + width: 100%; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + /* 条纹背景 */ + 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; + span { + color: #fff; + display: inline-block; + width: 140px; + height: 25px; + font-size: 12px; + text-align: center; + line-height: 25px; + background: #5487df; + } + /* 拖拽时隐藏组件 */ + .component { + display: none; + } + /* 拖拽时显示占位提示 */ + .drag-placement { + display: block; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/Carousel/config.ts b/src/components/DiyEditor/components/mobile/Carousel/config.ts new file mode 100644 index 00000000..6c790186 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/config.ts @@ -0,0 +1,44 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 轮播图属性 */ +export interface CarouselProperty { + // 选择模板 + swiperType: number + // 图片圆角 + borderRadius: number + // 页面边距 + pageMargin: number + // 图片边距 + imageMargin: number + // 分页类型 + pagingType: 'bullets' | 'fraction' | 'progressbar' + // 一行个数 + rowIndividual: number + // 添加图片 + items: CarouselItemProperty[] +} + +export interface CarouselItemProperty { + title: string + imgUrl: string + link: string +} + +// 定义组件 +export const component = { + id: 'Carousel', + name: '轮播图', + icon: 'system-uicons:carousel', + property: { + swiperType: 0, // 选择模板 + borderRadius: 0, // 图片圆角 + pageMargin: 0, // 页面边距 + imageMargin: 0, // 图片边距 + pagingType: 'bullets', // 分页类型 + rowIndividual: 2, // 一行个数 + items: [ + { imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg' }, + { imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg' } + ] as CarouselItemProperty[] + } +} as DiyComponent<CarouselProperty> diff --git a/src/components/DiyEditor/components/mobile/Carousel/index.vue b/src/components/DiyEditor/components/mobile/Carousel/index.vue new file mode 100644 index 00000000..e9a0ab39 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/index.vue @@ -0,0 +1,75 @@ +<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-if="property.swiperType === 0" + class="flex flex-col" + :style="{ + paddingLeft: property.pageMargin + 'px', + paddingRight: property.pageMargin + 'px' + }" + > + <div v-for="(item, index) in property.items" :key="index"> + <div + class="img-item" + :style="{ + marginBottom: property.imageMargin + 'px', + borderRadius: property.borderRadius + 'px' + }" + > + <img alt="" :src="item.imgUrl" /> + <div v-if="item.title" class="title">{{ item.title }}</div> + </div> + </div> + </div> + <el-carousel height="174px" v-else :type="property.swiperType === 3 ? 'card' : ''"> + <el-carousel-item v-for="(item, index) in property.items" :key="index"> + <div class="img-item" :style="{ borderRadius: property.borderRadius + 'px' }"> + <img alt="" :src="item.imgUrl" /> + <div v-if="item.title" class="title">{{ item.title }}</div> + </div> + </el-carousel-item> + </el-carousel> +</template> +<script setup lang="ts"> +import { CarouselProperty } from './config' + +/** 页面顶部导航栏 */ +defineOptions({ name: 'NavigationBar' }) + +const props = defineProps<{ property: CarouselProperty }>() +</script> + +<style scoped lang="scss"> +.img-item { + width: 100%; + position: relative; + overflow: hidden; + &:last-child { + margin: 0 !important; + } + /* 图片 */ + img { + width: 100%; + height: 100%; + display: block; + } + .title { + height: 36px; + width: 100%; + background-color: rgba(51, 51, 51, 0.8); + text-align: center; + line-height: 36px; + color: #fff; + position: absolute; + bottom: 0; + left: 0; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/Carousel/property.vue b/src/components/DiyEditor/components/mobile/Carousel/property.vue new file mode 100644 index 00000000..acaee35f --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/property.vue @@ -0,0 +1,125 @@ +<template> + <el-form label-width="80px" :model="formData"> + <el-form-item label="选择模板" prop="swiperType"> + <el-radio-group v-model="formData.swiperType"> + <el-tooltip class="item" content="一行一个" placement="bottom"> + <el-radio-button :label="0"> + <Icon icon="icon-park-twotone:multi-picture-carousel" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="轮播海报" placement="bottom"> + <el-radio-button :label="1"> + <Icon icon="system-uicons:carousel" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="多图单行" placement="bottom"> + <el-radio-button :label="2"> + <Icon icon="icon-park-twotone:carousel" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="立体轮播" placement="bottom"> + <el-radio-button :label="3"> + <Icon icon="ic:round-view-carousel" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + + <el-text tag="p">添加图片</el-text> + <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text> + + <!-- 图片广告 --> + <div v-if="formData.items[0]"> + <draggable + :list="formData.items" + :force-fallback="true" + :animation="200" + handle=".drag-icon" + class="m-t-8px" + > + <template #item="{ element, index }"> + <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px"> + <div class="flex flex-col items-start justify-between"> + <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" /> + <Icon + icon="ep:delete" + class="cursor-pointer text-red-5" + @click="handleDeleteImage(index)" + v-if="formData.items.length > 1" + /> + </div> + <div class="flex flex-1 flex-col items-center justify-between gap-8px"> + <UploadImg + v-model="element.imgUrl" + draggable="false" + height="80px" + width="100%" + class="min-w-80px" + /> + <!-- 标题 --> + <el-input v-model="element.title" placeholder="标题,选填" /> + <!-- 输入链接 --> + <el-input placeholder="链接,选填" v-model="element.link" /> + </div> + </div> + </template> + </draggable> + </div> + <el-button @click="handleAddImage" type="primary" plain class="w-full"> 添加图片 </el-button> + <el-form-item label="一行个数" prop="rowIndividual" v-show="formData.swiperType === 2"> + <!-- 单选框 --> + <el-radio-group v-model="formData.rowIndividual"> + <el-radio :label="2">2个</el-radio> + <el-radio :label="3">3个</el-radio> + <el-radio :label="4">4个</el-radio> + <el-radio :label="5">5个</el-radio> + <el-radio :label="6">6个</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="分页类型" prop="pagingType"> + <el-radio-group v-model="formData.pagingType"> + <el-radio :label="0">不显示</el-radio> + <el-radio label="bullets">样式一</el-radio> + <el-radio label="fraction">样式二</el-radio> + <el-radio label="progressbar">样式三</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="图片圆角" prop="borderRadius"> + <el-slider v-model="formData.borderRadius" :max="30" /> + </el-form-item> + <el-form-item label="页面边距" prop="pageMargin" v-show="formData.swiperType === 0"> + <el-slider v-model="formData.pageMargin" :max="20" /> + </el-form-item> + <el-form-item + label="图片边距" + prop="imageMargin" + v-show="formData.swiperType === 0 || formData.swiperType === 2" + > + <el-slider v-model="formData.imageMargin" :max="20" /> + </el-form-item> + </el-form> +</template> + +<script setup lang="ts"> +import draggable from 'vuedraggable' //拖拽组件 +import { CarouselItemProperty, CarouselProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 轮播图属性面板 +defineOptions({ name: 'CarouselProperty' }) + +const props = defineProps<{ modelValue: CarouselProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 添加图片 +const handleAddImage = () => { + formData.value.items.push({} as CarouselItemProperty) +} +// 删除图片 +const handleDeleteImage = (index) => { + formData.value.items.splice(index, 1) +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Divider/config.ts b/src/components/DiyEditor/components/mobile/Divider/config.ts new file mode 100644 index 00000000..9b553604 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/config.ts @@ -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> diff --git a/src/components/DiyEditor/components/mobile/Divider/index.vue b/src/components/DiyEditor/components/mobile/Divider/index.vue new file mode 100644 index 00000000..f7785043 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/index.vue @@ -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> diff --git a/src/components/DiyEditor/components/mobile/Divider/property.vue b/src/components/DiyEditor/components/mobile/Divider/property.vue new file mode 100644 index 00000000..3d7be26d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/property.vue @@ -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 :label="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 label="none"> + <Icon icon="tabler:box-padding" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="左右留边" placement="top"> + <el-radio-button label="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 { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'DividerProperty' }) +const props = defineProps<{ modelValue: DividerProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(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> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/config.ts b/src/components/DiyEditor/components/mobile/NavigationBar/config.ts new file mode 100644 index 00000000..b250f5f1 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/config.ts @@ -0,0 +1,38 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 顶部导航栏属性 */ +export interface NavigationBarProperty { + // 页面标题 + title: string + // 页面描述 + description: string + // 顶部导航高度 + navBarHeight: number + // 页面背景颜色 + backgroundColor: string + // 页面背景图片 + backgroundImage: string + // 样式类型:默认 | 沉浸式 + styleType: 'default' | 'immersion' + // 常驻显示 + alwaysShow: boolean + // 是否显示返回按钮 + showGoBack: boolean +} + +// 定义组件 +export const component = { + id: 'NavigationBar', + name: '顶部导航栏', + icon: 'tabler:layout-navbar', + property: { + title: '页面标题', + description: '', + navBarHeight: 35, + backgroundColor: '#f5f5f5', + backgroundImage: '', + styleType: 'default', + alwaysShow: true, + showGoBack: true + } +} as DiyComponent<NavigationBarProperty> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/index.vue b/src/components/DiyEditor/components/mobile/NavigationBar/index.vue new file mode 100644 index 00000000..953748c7 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/index.vue @@ -0,0 +1,60 @@ +<template> + <div + class="navigation-bar" + :style="{ + height: `${property.navBarHeight}px`, + backgroundColor: property.backgroundColor, + backgroundImage: `url(${property.backgroundImage})` + }" + > + <!-- 左侧 --> + <div class="left"> + <Icon icon="ep:arrow-left" v-show="property.showGoBack" /> + </div> + <!-- 中间 --> + <div + class="center" + :style="{ + height: `${property.navBarHeight}px`, + lineHeight: `${property.navBarHeight}px` + }" + > + {{ property.title }} + </div> + <!-- 右侧 --> + <div class="right"></div> + </div> +</template> +<script setup lang="ts"> +import { NavigationBarProperty } from './config' + +/** 页面顶部导航栏 */ +defineOptions({ name: 'NavigationBar' }) + +defineProps<{ property: NavigationBarProperty }>() +</script> +<style lang="scss" scoped> +.navigation-bar { + height: 35px; + background: #fff; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + /* 左边 */ + .left { + margin-left: 8px; + } + .center { + flex: 1; + text-align: center; + font-size: 14px; + line-height: 35px; + color: #333333; + } + /* 右边 */ + .right { + margin-right: 8px; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/property.vue b/src/components/DiyEditor/components/mobile/NavigationBar/property.vue new file mode 100644 index 00000000..c4ca4588 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/property.vue @@ -0,0 +1,63 @@ +<template> + <el-form label-width="80px" :model="formData" :rules="rules"> + <el-form-item label="页面标题" prop="title"> + <el-input v-model="formData!.title" placeholder="页面标题" maxlength="25" show-word-limit /> + </el-form-item> + <el-form-item label="页面描述" prop="description"> + <el-input + type="textarea" + v-model="formData!.description" + placeholder="用户通过微信分享给朋友时,会自动显示页面描述" + /> + </el-form-item> + <el-form-item label="样式" prop="styleType"> + <el-radio-group v-model="formData!.styleType"> + <el-radio label="default">默认</el-radio> + <el-radio label="immersion">沉浸式</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'immersion'"> + <el-radio-group v-model="formData!.alwaysShow"> + <el-radio :label="false">关闭</el-radio> + <el-radio :label="true">开启</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="高度" prop="navBarHeight"> + <el-slider + v-model="formData!.navBarHeight" + :max="100" + :min="35" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="返回按钮" prop="showGoBack"> + <el-switch v-model="formData!.showGoBack" /> + </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 { NavigationBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'NavigationBarProperty' }) +// 表单校验 +const rules = { + name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }] +} + +const props = defineProps<{ modelValue: NavigationBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/config.ts b/src/components/DiyEditor/components/mobile/NoticeBar/config.ts new file mode 100644 index 00000000..03e7143a --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/config.ts @@ -0,0 +1,39 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 公告栏属性 */ +export interface NoticeBarProperty { + // 图标地址 + iconUrl: string + // 公告内容列表 + contents: NoticeContentProperty[] + // 背景颜色 + backgroundColor: string + // 文字颜色 + textColor: string +} + +/** 内容属性 */ +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' + } +} as DiyComponent<NoticeBarProperty> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/index.vue b/src/components/DiyEditor/components/mobile/NoticeBar/index.vue new file mode 100644 index 00000000..dc360688 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/index.vue @@ -0,0 +1,26 @@ +<template> + <div + class="flex items-center 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> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/property.vue b/src/components/DiyEditor/components/mobile/NoticeBar/property.vue new file mode 100644 index 00000000..11e7f4b7 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/property.vue @@ -0,0 +1,77 @@ +<template> + <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-text tag="p"> 公告内容 </el-text> + <el-text type="info" size="small"> 拖动左上角的小圆点可以调整热词顺序 </el-text> + <template v-if="formData.contents.length"> + <VueDraggable + :list="formData.contents" + item-key="index" + handle=".drag-icon" + :forceFallback="true" + :animation="200" + class="m-t-8px" + > + <template #item="{ element, index }"> + <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px"> + <div class="flex flex-col items-start justify-between"> + <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" /> + <Icon + icon="ep:delete" + class="cursor-pointer text-red-5" + @click="handleDeleteContent(index)" + v-if="formData.contents.length > 1" + /> + </div> + <div class="w-full flex flex-col gap-8px"> + <el-input v-model="element.text" placeholder="请输入公告" /> + <el-input v-model="element.url" placeholder="请输入链接" /> + </div> + </div> + </template> + </VueDraggable> + </template> + <el-form-item label-width="0"> + <el-button @click="handleAddContent" type="primary" plain class="m-t-8px w-full"> + 添加内容 + </el-button> + </el-form-item> + </el-form> +</template> + +<script setup lang="ts"> +import { NoticeBarProperty, NoticeContentProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import VueDraggable from 'vuedraggable' +// 通知栏属性面板 +defineOptions({ name: 'NoticeBarProperty' }) +// 表单校验 +const rules = { + content: [{ required: true, message: '请输入公告', trigger: 'blur' }] +} + +const props = defineProps<{ modelValue: NoticeBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +/* 添加公告 */ +const handleAddContent = () => { + formData.value.contents.push({} as NoticeContentProperty) +} +/* 删除公告 */ +const handleDeleteContent = (index: number) => { + formData.value.contents.splice(index, 1) +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PageConfig/config.ts b/src/components/DiyEditor/components/mobile/PageConfig/config.ts new file mode 100644 index 00000000..f8e45e45 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PageConfig/config.ts @@ -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> diff --git a/src/components/DiyEditor/components/mobile/PageConfig/property.vue b/src/components/DiyEditor/components/mobile/PageConfig/property.vue new file mode 100644 index 00000000..278bc940 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PageConfig/property.vue @@ -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 { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'PageConfigProperty' }) +// 表单校验 +const rules = {} + +const props = defineProps<{ modelValue: PageConfigProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/config.ts b/src/components/DiyEditor/components/mobile/SearchBar/config.ts new file mode 100644 index 00000000..1241748d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/config.ts @@ -0,0 +1,35 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 搜索框属性 */ +export interface SearchProperty { + height: number // 搜索栏高度 + showScan: boolean // 显示扫一扫 + borderRadius: number // 框体样式 + placeholder: string // 占位文字 + placeholderPosition: PlaceholderPosition // 占位文字位置 + backgroundColor: string // 背景颜色 + borderColor: string // 框体颜色 + textColor: string // 字体颜色 + hotKeywords: string[] // 热词 +} + +// 文字位置 +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(249, 249, 249)', + borderColor: 'rgb(255, 255, 255)', + textColor: 'rgb(150, 151, 153)', + hotKeywords: [] + } +} as DiyComponent<SearchProperty> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/index.vue b/src/components/DiyEditor/components/mobile/SearchBar/index.vue new file mode 100644 index 00000000..e120405a --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/index.vue @@ -0,0 +1,80 @@ +<template> + <div + class="search-bar" + :style="{ + background: property.backgroundColor, + border: `1px solid ${property.backgroundColor}`, + color: property.textColor + }" + > + <!-- 搜索框 --> + <div + class="inner" + :style="{ + height: `${property.height}px`, + background: property.borderColor, + 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 { + position: relative; + /* 搜索框 */ + .inner { + position: relative; + width: calc(100% - 16px); + min-height: 28px; + margin: 5px auto; + display: flex; + align-items: center; + font-size: 14px; + + .placeholder { + display: flex; + align-items: center; + width: 100%; + padding: 0 8px; + gap: 2px; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-all; + white-space: nowrap; + } + + .right { + position: absolute; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/property.vue b/src/components/DiyEditor/components/mobile/SearchBar/property.vue new file mode 100644 index 00000000..9123ebe5 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/property.vue @@ -0,0 +1,100 @@ +<template> + <el-text tag="p"> 搜索热词 </el-text> + <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text> + + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <div v-if="formData.hotKeywords.length"> + <VueDraggable + :list="formData.hotKeywords" + item-key="index" + handle=".drag-icon" + :forceFallback="true" + :animation="200" + > + <template #item="{ index }"> + <div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px"> + <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" /> + <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" /> + <Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" /> + </div> + </template> + </VueDraggable> + </div> + <el-form-item label-width="0"> + <el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full"> + 添加热词 + </el-button> + </el-form-item> + <el-form-item label="框体样式"> + <el-radio-group v-model="formData!.borderRadius"> + <el-tooltip content="方形" placement="top"> + <el-radio-button :label="0"> + <Icon icon="tabler:input-search" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="圆形" placement="top"> + <el-radio-button :label="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 label="left"> + <Icon icon="ant-design:align-left-outlined" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="居中" placement="top"> + <el-radio-button label="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 label="框体颜色" prop="borderColor"> + <ColorInput v-model="formData.borderColor" /> + </el-form-item> + <el-form-item class="lef" label="文本颜色" prop="textColor"> + <ColorInput v-model="formData.textColor" /> + </el-form-item> + </el-form> +</template> + +<script setup lang="ts"> +import VueDraggable from 'vuedraggable' +import { usePropertyForm } from '@/components/DiyEditor/util' +import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config' + +/** 搜索框属性面板 */ +defineOptions({ name: 'SearchProperty' }) + +const props = defineProps<{ modelValue: SearchProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +/* 添加热词 */ +const handleAddHotWord = () => { + formData.value.hotKeywords.push('') +} +/* 删除热词 */ +const deleteHotWord = (index: number) => { + formData.value.hotKeywords.splice(index, 1) +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/TabBar/config.ts b/src/components/DiyEditor/components/mobile/TabBar/config.ts new file mode 100644 index 00000000..4a570a8e --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/config.ts @@ -0,0 +1,91 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 底部导航菜单属性 */ +export interface TabBarProperty { + // 选项列表 + items: TabBarItemProperty[] + // 主题 + theme: string + // 样式 + style: TabBarStyle +} + +// 选项属性 +export interface TabBarItemProperty { + name: string // 标签名称 + link: string // 链接 + iconUrl: string // 默认图标链接 + activeIconUrl: string // 选中的图标链接 +} + +// 样式 +export interface TabBarStyle { + // 背景类型 + backgroundType: 'color' | 'img' + // 背景颜色 或 图片链接 + background: string + // 默认颜色 + color: string + // 选中的颜色 + activeColor: string +} + +// 定义组件 +export const component = { + id: 'TabBar', + name: '底部导航', + icon: 'fluent:table-bottom-row-16-filled', + property: { + theme: 'red', + style: { + backgroundType: 'color', + background: '#fff', + color: '#282828', + activeColor: '#fc4141' + }, + items: [ + { + name: '首页', + link: '/', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-002.png' + }, + { + name: '分类', + link: '/pages/goods_cate/goods_cate', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-002.png' + }, + { + name: '购物车', + link: '/pages/order_addcart/order_addcart', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-002.png' + }, + { + name: '我的', + link: '/pages/user/index', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-002.png' + } + ] + } +} as DiyComponent<TabBarProperty> + +export const THEME_LIST = [ + { id: 'red', name: '中国红', icon: 'icon-park-twotone:theme', color: '#d10019' }, + { id: 'orange', name: '桔橙', icon: 'icon-park-twotone:theme', color: '#f37b1d' }, + { id: 'gold', name: '明黄', icon: 'icon-park-twotone:theme', color: '#fbbd08' }, + { id: 'green', name: '橄榄绿', icon: 'icon-park-twotone:theme', color: '#8dc63f' }, + { id: 'cyan', name: '天青', icon: 'icon-park-twotone:theme', color: '#1cbbb4' }, + { id: 'blue', name: '海蓝', icon: 'icon-park-twotone:theme', color: '#0081ff' }, + { id: 'purple', name: '姹紫', icon: 'icon-park-twotone:theme', color: '#6739b6' }, + { id: 'brightRed', name: '嫣红', icon: 'icon-park-twotone:theme', color: '#e54d42' }, + { id: 'forestGreen', name: '森绿', icon: 'icon-park-twotone:theme', color: '#39b54a' }, + { id: 'mauve', name: '木槿', icon: 'icon-park-twotone:theme', color: '#9c26b0' }, + { id: 'pink', name: '桃粉', icon: 'icon-park-twotone:theme', color: '#e03997' }, + { id: 'brown', name: '棕褐', icon: 'icon-park-twotone:theme', color: '#a5673f' }, + { id: 'grey', name: '玄灰', icon: 'icon-park-twotone:theme', color: '#8799a3' }, + { id: 'gray', name: '草灰', icon: 'icon-park-twotone:theme', color: '#aaaaaa' }, + { id: 'black', name: '墨黑', icon: 'icon-park-twotone:theme', color: '#333333' } +] diff --git a/src/components/DiyEditor/components/mobile/TabBar/index.vue b/src/components/DiyEditor/components/mobile/TabBar/index.vue new file mode 100644 index 00000000..266a20c5 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/index.vue @@ -0,0 +1,58 @@ +<template> + <div class="tab-bar"> + <div + class="tab-bar-bg" + :style="{ + background: + property.style.backgroundType === 'color' + ? property.style.background + : `url(${property.style.background})`, + backgroundSize: '100% 100%', + backgroundRepeat: 'no-repeat' + }" + > + <div v-for="(item, index) in property.items" :key="index" class="tab-bar-item"> + <img :src="index === 0 ? item.activeIconUrl : item.iconUrl" alt="" /> + <span :style="{ color: index === 0 ? property.style.activeColor : property.style.color }"> + {{ item.name }} + </span> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +import { TabBarProperty } from './config' + +/** 页面底部导航栏 */ +defineOptions({ name: 'TabBar' }) + +defineProps<{ property: TabBarProperty }>() +</script> +<style lang="scss" scoped> +.tab-bar { + width: 100%; + z-index: 2; + .tab-bar-bg { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + padding: 8px 0; + + .tab-bar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; + width: 100%; + + img { + width: 26px; + height: 26px; + border-radius: 4px; + } + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/TabBar/property.vue b/src/components/DiyEditor/components/mobile/TabBar/property.vue new file mode 100644 index 00000000..512bb5a8 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/property.vue @@ -0,0 +1,161 @@ +<template> + <div class="tab-bar"> + <!-- 表单 --> + <el-form :model="formData" label-width="80px"> + <el-form-item label="主题"> + <el-select v-model="formData!.theme" @change="handleThemeChange"> + <el-option + v-for="(theme, index) in THEME_LIST" + :key="index" + :label="theme.name" + :value="theme.id" + > + <template #default> + <div class="flex items-center justify-between"> + <Icon :icon="theme.icon" :color="theme.color" /> + <span>{{ theme.name }}</span> + </div> + </template> + </el-option> + </el-select> + </el-form-item> + <el-form-item label="默认颜色"> + <ColorInput v-model="formData!.style.color" /> + </el-form-item> + <el-form-item label="选中颜色"> + <ColorInput v-model="formData!.style.activeColor" /> + </el-form-item> + <el-form-item label="导航背景"> + <el-radio-group + v-model="formData!.style.backgroundType" + @change="handleBackgroundTypeChange" + > + <el-radio-button label="color">纯色</el-radio-button> + <el-radio-button label="img">图片</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="选择颜色" v-if="formData!.style.backgroundType === 'color'"> + <ColorInput v-model="formData!.style.background" /> + </el-form-item> + <el-form-item label="选择图片" v-if="formData!.style.backgroundType === 'img'"> + <UploadImg + v-model="formData!.style.background" + width="100%" + height="50px" + class="min-w-200px" + > + <template #tip> 建议尺寸 375 * 50 </template> + </UploadImg> + </el-form-item> + + <el-text tag="p">图标设置</el-text> + <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44 </el-text> + <draggable + :list="formData!.items" + item-key="index" + :forceFallback="true" + :animation="200" + handle=".drag-icon" + class="m-t-8px" + > + <template #item="{ element, index }"> + <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px"> + <div class="flex flex-col items-start justify-between"> + <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" /> + <Icon + icon="ep:delete" + class="cursor-pointer text-red-5" + @click="handleDeleteItem(index)" + v-if="formData.items.length > 1" + /> + </div> + <div class="w-full flex flex-col"> + <div class="m-b-8px flex items-center justify-around"> + <div class="flex flex-col items-center justify-between"> + <UploadImg + v-model="element.iconUrl" + width="40px" + height="40px" + :show-delete="false" + :show-btn-text="false" + /> + <el-text size="small">默认图片</el-text> + </div> + <div> + <UploadImg + v-model="element.activeIconUrl" + width="40px" + height="40px" + :show-delete="false" + :show-btn-text="false" + /> + <el-text>选中图片</el-text> + </div> + </div> + <el-form-item draggable="false" label-width="0" class="m-b-8px!"> + <el-input v-model="element.name" placeholder="请输入文字" /> + </el-form-item> + <el-form-item draggable="false" label-width="0" class="m-b-0!"> + <el-input v-model="element.link" placeholder="请选择链接" /> + </el-form-item> + </div> + </div> + </template> + </draggable> + + <el-form-item label-width="0"> + <!-- 添加导航按钮 --> + <el-tooltip content="最多添加5个"> + <el-button + @click="handleAddItem" + class="m-b-16px w-full" + type="primary" + plain + :disabled="formData!.items.length >= 5" + > + 添加导航 + </el-button> + </el-tooltip> + </el-form-item> + </el-form> + </div> +</template> + +<script setup lang="ts"> +import draggable from 'vuedraggable' //拖拽组件 +import { TabBarItemProperty, TabBarProperty, THEME_LIST } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 底部导航栏 +defineOptions({ name: 'TabBarProperty' }) + +const props = defineProps<{ modelValue: TabBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 缓存背景:当背景类型切换时,防止参数丢失 +const backgroundCache = ref('') +const handleBackgroundTypeChange = () => { + const background = formData.value!.style.background + formData.value!.style.background = backgroundCache.value + backgroundCache.value = background +} + +/** 添加导航项 */ +const handleAddItem = () => { + formData?.value?.items?.push({} as TabBarItemProperty) +} +/** 删除导航项 */ +const handleDeleteItem = (index: number) => { + formData?.value?.items?.splice(index, 1) +} + +// 要的主题 +const handleThemeChange = () => { + const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme) + if (theme?.color) { + formData.value.style.activeColor = theme.color + } +} +</script> + +<style lang="scss" scoped></style> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/config.ts b/src/components/DiyEditor/components/mobile/TitleBar/config.ts new file mode 100644 index 00000000..3d486cc3 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/config.ts @@ -0,0 +1,65 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 标题栏属性 */ +export interface TitleBarProperty { + // 主标题 + title: string + // 副标题 + description: string + // 标题大小 + titleSize: number + // 描述大小 + descriptionSize: number + // 标题粗细 + titleWeight: number + // 显示位置 + position: 'left' | 'center' + // 描述粗细 + descriptionWeight: number + // 标题颜色 + titleColor: string + // 描述颜色 + descriptionColor: string + // 背景颜色 + backgroundColor: string + // 底部分割线 + showBottomBorder: false + // 查看更多 + more: { + // 是否显示查看更多 + show: false + // 样式选择 + type: 'text' | 'icon' | 'all' + // 自定义文字 + text: string + // 链接 + url: string + } +} + +// 定义组件 +export const component = { + id: 'TitleBar', + name: '标题栏', + icon: 'material-symbols:line-start', + property: { + title: '主标题', + description: '副标题', + titleSize: 16, + descriptionSize: 12, + titleWeight: 400, + position: 'left', + descriptionWeight: 200, + titleColor: 'rgba(50, 50, 51, 10)', + descriptionColor: 'rgba(150, 151, 153, 10)', + backgroundColor: 'rgba(255, 255, 255, 10)', + showBottomBorder: false, + more: { + //查看更多 + show: false, + type: 'icon', + text: '查看更多', + url: '' + } + } +} as DiyComponent<TitleBarProperty> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/index.vue b/src/components/DiyEditor/components/mobile/TitleBar/index.vue new file mode 100644 index 00000000..aab65779 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/index.vue @@ -0,0 +1,80 @@ +<template> + <div + class="title-bar" + :style="{ + background: property.backgroundColor, + borderBottom: property.showBottomBorder ? '1px solid #F9F9F9' : '1px solid #fff' + }" + > + <div> + <!-- 标题 --> + <div + :style="{ + fontSize: `${property.titleSize}px`, + fontWeight: property.titleWeight, + color: property.titleColor, + textAlign: property.position + }" + v-if="property.title" + > + {{ property.title }} + </div> + <!-- 副标题 --> + <div + :style="{ + fontSize: `${property.descriptionSize}px`, + fontWeight: property.descriptionWeight, + color: property.descriptionColor, + textAlign: property.position + }" + class="m-t-8px" + v-if="property.description" + > + {{ property.description }} + </div> + </div> + <!-- 更多 --> + <div + class="more" + v-show="property.more.show" + :style="{ + color: property.more.type === 'text' ? '#38f' : '' + }" + > + {{ property.more.type === 'icon' ? '' : property.more.text }} + <Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" /> + </div> + </div> +</template> +<script setup lang="ts"> +import { TitleBarProperty } from './config' + +/** 标题栏 */ +defineOptions({ name: 'TitleBar' }) + +defineProps<{ property: TitleBarProperty }>() +</script> +<style scoped lang="scss"> +.title-bar { + border: 2px solid #fff; + box-sizing: border-box; + width: 100%; + padding: 8px 16px; + min-height: 20px; + position: relative; + + /* 更多 */ + .more { + position: absolute; + right: 8px; + top: 0; + bottom: 0; + margin: auto; + font-size: 10px; + color: #969799; + display: flex; + align-items: center; + justify-content: center; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/property.vue b/src/components/DiyEditor/components/mobile/TitleBar/property.vue new file mode 100644 index 00000000..3e4dac2d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/property.vue @@ -0,0 +1,115 @@ +<template> + <section class="title-bar"> + <el-form label-width="85px" :model="formData" :rules="rules"> + <el-form-item label="主标题" prop="title"> + <el-input + v-model="formData.title" + placeholder="请输入主标题" + show-word-limit + maxlength="20" + /> + </el-form-item> + <el-form-item label="副标题" prop="description"> + <el-input + type="textarea" + v-model="formData.description" + placeholder="请输入副标题" + maxlength="50" + show-word-limit + /> + </el-form-item> + <el-form-item label="显示位置" prop="position"> + <el-radio-group v-model="formData!.position"> + <el-tooltip content="居左" placement="top"> + <el-radio-button label="left"> + <Icon icon="ant-design:align-left-outlined" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="居中" placement="top"> + <el-radio-button label="center"> + <Icon icon="ant-design:align-center-outlined" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="标题大小" prop="titleSize"> + <el-slider v-model="formData.titleSize" :max="60" :min="10" show-input input-size="small" /> + </el-form-item> + <el-form-item label="副标题大小" prop="descriptionSize"> + <el-slider + v-model="formData.descriptionSize" + :max="60" + :min="10" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="标题粗细" prop="titleWeight"> + <el-slider + v-model="formData.titleWeight" + :min="100" + :max="900" + :step="100" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="副标题粗细" prop="descriptionWeight"> + <el-slider + v-model="formData.descriptionWeight" + :min="100" + :max="900" + :step="100" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="标题颜色" prop="titleColor"> + <ColorInput v-model="formData.titleColor" /> + </el-form-item> + <el-form-item label="副标题颜色" prop="descriptionColor"> + <ColorInput v-model="formData.descriptionColor" /> + </el-form-item> + <el-form-item label="背景颜色" prop="backgroundColor"> + <ColorInput v-model="formData.backgroundColor" /> + </el-form-item> + <el-form-item label="底部分割线" prop="showBottomBorder"> + <el-switch v-model="formData!.showBottomBorder" /> + </el-form-item> + <el-form-item label="查看更多" prop="more.show"> + <el-checkbox v-model="formData.more.show" /> + </el-form-item> + <!-- 更多样式选择 --> + <template v-if="formData.more.show"> + <el-form-item label="样式" prop="more.type"> + <el-radio-group v-model="formData.more.type"> + <el-radio label="text">文字</el-radio> + <el-radio label="icon">图标</el-radio> + <el-radio label="all">文字+图标</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'"> + <el-input v-model="formData.more.text" /> + </el-form-item> + <el-form-item label="跳转链接" prop="more.url"> + <el-input v-model="formData.more.url" placeholder="请输入跳转链接" /> + </el-form-item> + </template> + </el-form> + </section> +</template> +<script setup lang="ts"> +import { TitleBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'TitleBarProperty' }) + +const props = defineProps<{ modelValue: TitleBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 表单校验 +const rules = {} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/index.ts b/src/components/DiyEditor/components/mobile/index.ts new file mode 100644 index 00000000..c0dc67da --- /dev/null +++ b/src/components/DiyEditor/components/mobile/index.ts @@ -0,0 +1,61 @@ +/* + * 组件注册 + * + * 组件规范: + * 1. 每个子目录就是一个独立的组件,每个目录包括以下三个文件: + * 2. config.ts:组件配置,必选,用于定义组件、组件默认的属性、定义属性的类型 + * 3. index.vue:组件展示,用于展示组件的渲染效果。可以不提供,如 Page(页面设置),只需要属性配置表单即可 + * 4. property.vue:组件属性表单,用于配置组件,必选, + * + * 注: + * 组件ID以config.ts中配置的id为准,与组件目录的名称无关,但还是建议组件目录的名称与组件ID保持一致 + */ + +// 导入组件界面模块 +const viewModules: Record<string, any> = import.meta.glob('./*/*.vue') +// 导入配置模块 +const configModules: Record<string, any> = import.meta.glob('./*/config.ts', { eager: true }) + +// 界面模块 +const components = {} +// 组件配置模块 +const componentConfigs = {} + +// 组件界面的类型 +type ViewType = 'index' | 'property' + +/** + * 注册组件的界面模块 + * + * @param componentId 组件ID + * @param configPath 配置模块的文件路径 + * @param viewType 组件界面的类型 + */ +const registerComponentViewModule = ( + componentId: string, + configPath: string, + viewType: ViewType +) => { + const viewPath = configPath.replace('config.ts', `${viewType}.vue`) + const viewModule = viewModules[viewPath] + if (viewModule) { + // 定义异步组件 + components[componentId] = defineAsyncComponent(viewModule) + } +} + +// 注册 +Object.keys(configModules).forEach((modulePath: string) => { + const component = configModules[modulePath].component + const componentId = component?.id + if (componentId) { + // 注册组件 + componentConfigs[componentId] = component + // 注册预览界面 + registerComponentViewModule(componentId, modulePath, 'index') + // 注册属性配置表单 + registerComponentViewModule(`${componentId}Property`, modulePath, 'property') + } +}) + +export { components, componentConfigs } diff --git a/src/components/DiyEditor/index.vue b/src/components/DiyEditor/index.vue new file mode 100644 index 00000000..520cd497 --- /dev/null +++ b/src/components/DiyEditor/index.vue @@ -0,0 +1,539 @@ +<template> + <el-container class="editor"> + <!-- 顶部:工具栏 --> + <el-header class="editor-header"> + <!-- 左侧操作区 --> + <slot name="toolBarLeft"></slot> + <!-- 中心操作区 --> + <div class="header-center flex flex-1 items-center justify-center"> + <span>{{ title }}</span> + </div> + <!-- 右侧操作区 --> + <el-button-group class="header-right"> + <el-tooltip content="重置"> + <el-button @click="handleReset"> + <Icon icon="system-uicons:reset-alt" :size="24" /> + </el-button> + </el-tooltip> + <el-tooltip content="预览"> + <el-button @click="handlePreview"> + <Icon icon="ep:view" :size="24" /> + </el-button> + </el-tooltip> + <el-tooltip content="保存"> + <el-button @click="handleSave"> + <Icon icon="ep:check" :size="24" /> + </el-button> + </el-tooltip> + </el-button-group> + </el-header> + <!-- 中心区域 --> + <el-container class="editor-container"> + <!-- 左侧:组件库 --> + <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" /> + <!-- 中心设计区域 --> + <div class="editor-center page-prop-area" @click="handlePageSelected"> + <div class="editor-design"> + <!-- 手机顶部 --> + <div class="editor-design-top"> + <!-- 手机顶部状态栏 --> + <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" /> + <!-- 手机顶部导航栏 --> + <NavigationBar + v-if="showNavigationBar" + :property="navigationBarComponent.property" + @click="handleNavigationBarSelected" + :class="[ + 'component', + { active: selectedComponent?.id === navigationBarComponent.id } + ]" + /> + </div> + <!-- 手机页面编辑区域 --> + <el-scrollbar class="editor-design-center" height="100%" view-class="page-prop-area"> + <div + class="phone-container" + :style="{ + backgroundColor: pageConfigComponent.property.backgroundColor, + backgroundImage: `url(${pageConfigComponent.property.backgroundImage})` + }" + > + <draggable + class="drag-area page-prop-area" + v-model="pageComponents" + item-key="index" + :animation="200" + filter=".component-toolbar" + ghost-class="draggable-ghost" + :force-fallback="true" + group="component" + @change="handleComponentChange" + > + <template #item="{ element, index }"> + <div class="component-box" @click="handleComponentSelected(element, index)"> + <!-- 左侧组件名 --> + <div + :class="['component-name', { active: selectedComponentIndex === index }]" + v-if="element.name" + > + {{ element.name }} + </div> + <!-- 组件内容区 --> + <component + :is="element.id" + :property="element.property" + :class="['component', { active: selectedComponentIndex === index }]" + :data-type="element.id" + /> + <!-- 左侧:组件操作工具栏 --> + <div + class="component-toolbar" + v-if="element.name && selectedComponentIndex === index" + > + <el-button-group type="primary"> + <el-tooltip content="上移" placement="right"> + <el-button + :disabled="index === 0" + @click.stop="handleMoveComponent(index, -1)" + > + <Icon icon="ep:arrow-up" /> + </el-button> + </el-tooltip> + <el-tooltip content="下移" placement="right"> + <el-button + :disabled="index === pageComponents.length - 1" + @click.stop="handleMoveComponent(index, 1)" + > + <Icon icon="ep:arrow-down" /> + </el-button> + </el-tooltip> + <el-tooltip content="复制" placement="right"> + <el-button @click.stop="handleCopyComponent(index)"> + <Icon icon="ep:copy-document" /> + </el-button> + </el-tooltip> + <el-tooltip content="删除" placement="right"> + <el-button @click.stop="handleDeleteComponent(index)"> + <Icon icon="ep:delete" /> + </el-button> + </el-tooltip> + </el-button-group> + </div> + </div> + </template> + </draggable> + </div> + </el-scrollbar> + <!-- 手机底部导航 --> + <div + v-if="showTabBar" + :class="[ + 'editor-design-bottom', + 'component', + { active: selectedComponent?.id === tabBarComponent.id } + ]" + > + <TabBar :property="tabBarComponent.property" @click="handleTabBarSelected" /> + </div> + </div> + </div> + <!-- 右侧属性面板 --> + <el-aside class="editor-right" width="350px" v-if="selectedComponent?.property"> + <el-card + shadow="never" + body-class="h-[calc(100%-var(--el-card-padding)-var(--el-card-padding))]" + class="h-full" + > + <!-- 组件名称 --> + <template #header> + <div class="flex items-center gap-8px"> + <Icon :icon="selectedComponent.icon" color="gray" /> + <span>{{ selectedComponent.name }}</span> + </div> + </template> + <el-scrollbar + class="m-[calc(0px-var(--el-card-padding))]" + view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property" + > + <component + :is="selectedComponent.id + 'Property'" + v-model="selectedComponent.property" + /> + </el-scrollbar> + </el-card> + </el-aside> + </el-container> + </el-container> +</template> +<script lang="ts"> +// 注册所有的组件 +import { components } from './components/mobile/index' +export default { + components: { ...components } +} +</script> +<script lang="ts" setup> +import draggable from 'vuedraggable' +import ComponentLibrary from './components/ComponentLibrary.vue' +import NavigationBar from './components/mobile/NavigationBar/index.vue' +import TabBar from './components/mobile/TabBar/index.vue' +import { cloneDeep, includes } from 'lodash-es' +import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config' +import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config' +import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/config' +import { isString } from '@/utils/is' +import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util' +import { componentConfigs } from '@/components/DiyEditor/components/mobile' + +/** 页面装修详情页 */ +defineOptions({ name: 'DiyPageDetail' }) + +// 消息弹窗 +const message = useMessage() +// 左侧组件库 +const componentLibrary = ref() +// 页面设置组件 +const pageConfigComponent = ref<DiyComponent<any>>(cloneDeep(PAGE_CONFIG_COMPONENT)) +// 顶部导航栏 +const navigationBarComponent = ref<DiyComponent<any>>(cloneDeep(NAVIGATION_BAR_COMPONENT)) +// 底部导航菜单 +const tabBarComponent = ref<DiyComponent<any>>(cloneDeep(TAB_BAR_COMPONENT)) + +// 选中的组件,默认选中顶部导航栏 +const selectedComponent = ref<DiyComponent<any>>(unref(pageConfigComponent)) +// 选中的组件索引 +const selectedComponentIndex = ref<number>(-1) +// 组件列表 +const pageComponents = ref<DiyComponent<any>[]>([]) +// 定义属性 +const props = defineProps<{ + modelValue: string | PageConfig + title: string + libs: DiyComponentLibrary[] // 组件库 + showNavigationBar: boolean + showTabBar: boolean +}>() + +// 监听传入的页面配置 +watch( + () => props.modelValue, + () => { + const modelValue = isString(props.modelValue) + ? (JSON.parse(props.modelValue) as PageConfig) + : props.modelValue + pageConfigComponent.value.property = modelValue?.page || PAGE_CONFIG_COMPONENT.property + navigationBarComponent.value.property = + modelValue?.navigationBar || NAVIGATION_BAR_COMPONENT.property + tabBarComponent.value.property = modelValue?.tabBar || TAB_BAR_COMPONENT.property + // 查找对应的页面组件 + pageComponents.value = (modelValue?.components || []).map((item) => { + const component = componentConfigs[item.id] + return { ...component, property: item.property } + }) + }, + { + immediate: true + } +) +// 保存 +const handleSave = () => { + const pageConfig = { + page: pageConfigComponent.value.property, + navigationBar: navigationBarComponent.value.property, + tabBar: tabBarComponent.value.property, + components: pageComponents.value.map((component) => { + // 只保留APP有用的字段 + return { id: component.id, property: component.property } + }) + } as PageConfig + // 发送数据更新通知 + const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig + emits('update:modelValue', modelValue) + // 发送保存通知 + emits('save', pageConfig) +} + +// 处理页面选中:显示属性表单 +const handlePageSelected = (event: any) => { + // 配置了样式 page-prop-area 的元素,才显示页面设置 + if (includes(event?.target?.classList, 'page-prop-area')) { + handleComponentSelected(unref(pageConfigComponent)) + } +} + +/** + * 选中组件 + * + * @param component 组件 + * @param index 组件的索引 + */ +const handleComponentSelected = (component: DiyComponent<any>, index: number = -1) => { + selectedComponent.value = component + selectedComponentIndex.value = index +} + +// 选中顶部导航栏 +const handleNavigationBarSelected = () => { + handleComponentSelected(unref(navigationBarComponent)) +} + +// 选中底部导航菜单 +const handleTabBarSelected = () => { + handleComponentSelected(unref(tabBarComponent)) +} + +// 组件变动 +const handleComponentChange = (dragEvent: any) => { + // 新增,即从组件库拖拽添加组件 + if (dragEvent.added) { + const { element, newIndex } = dragEvent.added + handleComponentSelected(element, newIndex) + } else if (dragEvent.moved) { + // 拖拽排序 + const { newIndex } = dragEvent.moved + // 保持选中 + selectedComponentIndex.value = newIndex + } +} + +// 交换组件 +const swapComponent = (oldIndex: number, newIndex: number) => { + ;[pageComponents.value[oldIndex], pageComponents.value[newIndex]] = [ + pageComponents.value[newIndex], + pageComponents.value[oldIndex] + ] + // 保持选中 + selectedComponentIndex.value = newIndex +} + +/** 移动组件 */ +const handleMoveComponent = (index: number, direction: number) => { + const newIndex = index + direction + if (newIndex < 0 || newIndex >= pageComponents.value.length) return + + swapComponent(index, newIndex) +} +/** 复制组件 */ +const handleCopyComponent = (index: number) => { + const component = cloneDeep(pageComponents.value[index]) + pageComponents.value.splice(index + 1, 0, component) +} +/** + * 删除组件 + * @param index 当前组件index + */ +const handleDeleteComponent = (index: number) => { + // 删除组件 + pageComponents.value.splice(index, 1) + if (index < pageComponents.value.length) { + // 1. 不是最后一个组件时,删除后选中下面的组件 + let bottomIndex = index + handleComponentSelected(pageComponents.value[bottomIndex], bottomIndex) + } else if (pageComponents.value.length > 0) { + // 2. 不是第一个组件时,删除后选中上面的组件 + let topIndex = index - 1 + handleComponentSelected(pageComponents.value[topIndex], topIndex) + } else { + // 3. 组件全部删除之后,显示页面设置 + handleComponentSelected(unref(pageConfigComponent)) + } +} + +// 工具栏操作 +const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue']) +// 重置 +const handleReset = () => { + message.warning('开发中~') + emits('reset') +} +// 预览 +const handlePreview = () => { + message.warning('开发中~') + emits('preview') +} +</script> +<style lang="scss" scoped> +.editor { + height: 100%; + margin: calc(0px - var(--app-content-padding)); + display: flex; + flex-direction: column; +} +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + height: auto; + padding: 0; + border-bottom: solid 1px var(--el-border-color); + background-color: var(--el-bg-color); + + .header-right { + height: 100%; + .el-button { + height: 100%; + } + } + + :deep(.el-radio-button__inner), + :deep(.el-button) { + border-top: none !important; + border-bottom: none !important; + border-radius: 0 !important; + } +} +.editor-container { + height: calc( + 100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 42px + ); + /* 右侧属性面板 */ + .editor-right { + flex-shrink: 0; + box-shadow: -8px 0 8px -8px rgba(0, 0, 0, 0.12); + + :deep(.el-card__header) { + padding: 8px 16px; + } + + .property-group { + /* 属性分组 */ + :deep(.el-card__header) { + border: none; + background: var(--el-bg-color-page); + } + } + } + + /* 中心 */ + .editor-center { + flex: 1 1 0; + padding: 16px 0; + background-color: var(--app-content-bg-color); + display: flex; + justify-content: center; + + .editor-design { + position: relative; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + + /* 组件 */ + .component { + border: 1px solid #fff; + width: 375px !important; + + &:hover { + border: 1px dashed #155bd4; + } + } + .component.active { + border: 2px solid #155bd4 !important; + } + + .editor-design-top { + width: 379px; + + .status-bar { + height: 20px; + width: 100%; + background-color: #fff; + } + + .navigation-bar { + width: 100%; + } + } + + .editor-design-bottom { + width: 379px; + } + + .editor-design-center { + width: 100%; + flex: 1 1 0; + + :deep(.el-scrollbar__view) { + height: 100%; + } + + /* 主体内容 */ + .phone-container { + height: 100%; + box-sizing: border-box; + cursor: move; + position: relative; + background-repeat: no-repeat; + background-size: 100% 100%; + width: 379px; + margin: 0 auto; + + .drag-area { + height: 100%; + } + + /* 组件容器 */ + .component-box { + width: 100%; + position: relative; + /* 组件名称 */ + .component-name { + position: absolute; + width: 80px; + text-align: center; + line-height: 25px; + height: 25px; + background: #fff; + font-size: 12px; + left: -80px; + top: 0; + box-shadow: + 0 0 4px #00000014, + 0 2px 6px #0000000f, + 0 4px 8px 2px #0000000a; + } + .component-name.active { + background: #2d8cf0; + color: #fff; + } + /* 组件操作按钮 */ + .component-toolbar { + position: absolute; + top: 0; + right: -50px; + + .el-button-group { + display: inline-flex; + flex-direction: column; + } + .el-button-group > .el-button:first-child { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-top-right-radius: var(--el-border-radius-base); + border-bottom-color: var(--el-button-divide-border-color); + } + .el-button-group > .el-button:last-child { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: var(--el-border-radius-base); + border-top-color: var(--el-button-divide-border-color); + } + .el-button-group .el-button--primary:not(:first-child):not(:last-child) { + border-top-color: var(--el-button-divide-border-color); + border-bottom-color: var(--el-button-divide-border-color); + } + .el-button-group > .el-button:not(:last-child) { + margin-bottom: -1px; + margin-right: 0; + } + } + } + } + } + } + } +} +</style> diff --git a/src/components/DiyEditor/util.ts b/src/components/DiyEditor/util.ts new file mode 100644 index 00000000..407efa30 --- /dev/null +++ b/src/components/DiyEditor/util.ts @@ -0,0 +1,59 @@ +import { ref, Ref } from 'vue' +import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/PageConfig/config' +import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config' +import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config' + +export interface DiyComponent<T> { + id: string + name: string + icon: string + property: T +} + +export interface DiyComponentLibrary { + name: string + extended: boolean + components: string[] +} + +// 页面配置 +export interface PageConfig { + // 页面属性 + page: PageConfigProperty + // 顶部导航栏属性 + navigationBar: NavigationBarProperty + // 底部导航菜单属性 + tabBar: TabBarProperty + // 页面组件列表 + components: PageComponent[] +} +// 页面组件,只保留组件ID,组件属性 +export interface PageComponent extends Pick<DiyComponent<any>, 'id' | 'property'> {} + +// 属性表单监听 +export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: Ref<T> } { + const formData = ref<T>() + // 监听属性数据变动 + watch( + () => modelValue, + () => { + formData.value = modelValue + }, + { + deep: true, + immediate: true + } + ) + // 监听表单数据变动 + watch( + () => formData.value, + () => { + emit('update:modelValue', formData.value) + }, + { + deep: true + } + ) + + return { formData } +} diff --git a/src/components/UploadFile/src/UploadImg.vue b/src/components/UploadFile/src/UploadImg.vue index 3cfc0a73..996fe40b 100644 --- a/src/components/UploadFile/src/UploadImg.vue +++ b/src/components/UploadFile/src/UploadImg.vue @@ -18,15 +18,15 @@ <div class="upload-handle" @click.stop> <div class="handle-icon" @click="editImg"> <Icon icon="ep:edit" /> - <span>{{ t('action.edit') }}</span> + <span v-if="showBtnText">{{ t('action.edit') }}</span> </div> <div class="handle-icon" @click="imgViewVisible = true"> <Icon icon="ep:zoom-in" /> - <span>{{ t('action.detail') }}</span> + <span v-if="showBtnText">{{ t('action.detail') }}</span> </div> - <div class="handle-icon" @click="deleteImg"> + <div class="handle-icon" @click="deleteImg" v-if="showDelete"> <Icon icon="ep:delete" /> - <span>{{ t('action.del') }}</span> + <span v-if="showBtnText">{{ t('action.del') }}</span> </div> </div> </template> @@ -81,7 +81,11 @@ const props = defineProps({ fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"]) height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px) width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px) - borderradius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px) + borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px) + // 是否显示删除按钮 + showDelete: propTypes.bool.def(true), + // 是否显示按钮文字 + showBtnText: propTypes.bool.def(true) }) const { t } = useI18n() // 国际化 const message = useMessage() // 消息弹窗 diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts index aa260cf1..70dc5b2f 100644 --- a/src/router/modules/remaining.ts +++ b/src/router/modules/remaining.ts @@ -459,6 +459,34 @@ const remainingRouter: AppRouteRecordRaw[] = [ component: () => import('@/views/pay/cashier/index.vue') } ] + }, + { + path: '/diy', + name: 'DiyCenter', + meta: { hidden: true }, + component: Layout, + children: [ + { + path: 'template/decorate/:id', + name: 'DiyTemplateDecorate', + meta: { + title: '模板装修', + noCache: true, + hidden: true + }, + component: () => import('@/views/mall/promotion/diy/template/decorate.vue') + }, + { + path: 'page/decorate/:id', + name: 'DiyPageDecorate', + meta: { + title: '页面装修', + noCache: true, + hidden: true + }, + component: () => import('@/views/mall/promotion/diy/page/decorate.vue') + } + ] } ] diff --git a/src/views/mall/promotion/diy/page/DiyPageForm.vue b/src/views/mall/promotion/diy/page/DiyPageForm.vue new file mode 100644 index 00000000..e0cb18b6 --- /dev/null +++ b/src/views/mall/promotion/diy/page/DiyPageForm.vue @@ -0,0 +1,114 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="页面名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入页面名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="预览图" prop="previewImageUrls"> + <UploadImgs v-model="formData.previewImageUrls" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as DiyPageApi from '@/api/mall/promotion/diy/page' + +/** 装修页面表单 */ +defineOptions({ name: 'DiyPageForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + remark: undefined, + previewImageUrls: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '页面名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const diyPage = await DiyPageApi.getDiyPage(id) // 处理预览图 + if (diyPage?.previewImageUrls?.length > 0) { + diyPage.previewImageUrls = diyPage.previewImageUrls.map((url: string) => { + return { url } + }) + } + formData.value = diyPage + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + // 处理预览图 + const previewImageUrls = formData.value.previewImageUrls.map((item) => { + return item['url'] ? item['url'] : item + }) + const data = { ...formData.value, previewImageUrls } as unknown as DiyPageApi.DiyPageVO + if (formType.value === 'create') { + await DiyPageApi.createDiyPage(data) + message.success(t('common.createSuccess')) + } else { + await DiyPageApi.updateDiyPage(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + remark: undefined, + previewImageUrls: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/diy/page/decorate.vue b/src/views/mall/promotion/diy/page/decorate.vue new file mode 100644 index 00000000..76336bc4 --- /dev/null +++ b/src/views/mall/promotion/diy/page/decorate.vue @@ -0,0 +1,99 @@ +<template> + <DiyEditor + v-if="formData && !formLoading" + v-model="formData.property" + :title="formData.name" + :libs="componentLibs" + :show-navigation-bar="true" + :show-tab-bar="false" + @save="submitForm" + /> +</template> +<script setup lang="ts"> +import * as DiyPageApi from '@/api/mall/promotion/diy/page' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { DiyComponentLibrary } from '@/components/DiyEditor/util' + +/** 装修页面表单 */ +defineOptions({ name: 'DiyPageDecorate' }) + +// 组件库 +const componentLibs = [ + { + name: '基础组件', + extended: true, + components: [ + 'SearchBar', + 'NoticeBar', + 'GridNavigation', + 'ListNavigation', + 'Divider', + 'TitleBar' + ] + }, + { name: '图文组件', extended: true, components: ['Carousel'] }, + { name: '商品组件', extended: true, components: ['ProductCard'] }, + { + name: '会员组件', + extended: true, + components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard'] + }, + { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] } +] as DiyComponentLibrary[] + +const message = useMessage() // 消息弹窗 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<DiyPageApi.DiyPageVO>() +const formRef = ref() // 表单 Ref + +// 获取详情 +const getPageDetail = async (id: any) => { + formLoading.value = true + try { + formData.value = await DiyPageApi.getDiyPage(id) + } finally { + formLoading.value = false + } +} +// 提交表单 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + // 提交请求 + formLoading.value = true + try { + await DiyPageApi.updateDiyPage(unref(formData)!) + message.success('保存成功') + } finally { + formLoading.value = false + } +} + +// 重置表单 +const resetForm = () => { + formData.value = { + id: undefined, + templateId: undefined, + name: '', + remark: '', + previewImageUrls: [], + property: '' + } as DiyPageApi.DiyPageVO + formRef.value?.resetFields() +} + +/** 初始化 **/ +const { currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 +const route = useRoute() +onMounted(() => { + resetForm() + if (!route.params.id) { + message.warning('参数错误,页面编号不能为空!') + delView(unref(currentRoute)) + return + } + getPageDetail(route.params.id) +}) +</script> diff --git a/src/views/mall/promotion/diy/page/index.vue b/src/views/mall/promotion/diy/page/index.vue new file mode 100644 index 00000000..6436c2f7 --- /dev/null +++ b/src/views/mall/promotion/diy/page/index.vue @@ -0,0 +1,189 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="页面名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入页面名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:diy-page:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="预览图" align="center" prop="previewImageUrls"> + <template #default="scope"> + <el-image + class="h-40px max-w-40px" + v-for="(url, index) in scope.row.previewImageUrls" + :key="index" + :src="url" + :preview-src-list="scope.row.previewImageUrls" + :initial-index="index" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="页面名称" align="center" prop="name" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="handleDecorate(scope.row.id)" + v-hasPermi="['promotion:diy-page:update']" + > + 装修 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:diy-page:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['promotion:diy-page:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DiyPageForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as DiyPageApi from '@/api/mall/promotion/diy/page' +import DiyPageForm from './DiyPageForm.vue' + +/** 装修页面 */ +defineOptions({ name: 'DiyPage' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiyPageApi.getDiyPagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiyPageApi.deleteDiyPage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 打开装修页面 */ +const { push } = useRouter() +const handleDecorate = (id: number) => { + push({ name: 'DiyPageDecorate', params: { id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/diy/template/DiyTemplateForm.vue b/src/views/mall/promotion/diy/template/DiyTemplateForm.vue new file mode 100644 index 00000000..e4289f65 --- /dev/null +++ b/src/views/mall/promotion/diy/template/DiyTemplateForm.vue @@ -0,0 +1,115 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="模板名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模板名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + <el-form-item label="预览图" prop="previewImageUrls"> + <UploadImgs v-model="formData.previewImageUrls" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' + +/** 装修模板表单 */ +defineOptions({ name: 'DiyTemplateForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + remark: undefined, + previewImageUrls: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const diyTemplate = await DiyTemplateApi.getDiyTemplate(id) + // 处理预览图 + if (diyTemplate?.previewImageUrls?.length > 0) { + diyTemplate.previewImageUrls = diyTemplate.previewImageUrls.map((url: string) => { + return { url } + }) + } + formData.value = diyTemplate + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + // 处理预览图 + const previewImageUrls = formData.value.previewImageUrls.map((item) => { + return item['url'] ? item['url'] : item + }) + const data = { ...formData.value, previewImageUrls } as unknown as DiyTemplateApi.DiyTemplateVO + if (formType.value === 'create') { + await DiyTemplateApi.createDiyTemplate(data) + message.success(t('common.createSuccess')) + } else { + await DiyTemplateApi.updateDiyTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + remark: undefined, + previewImageUrls: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/diy/template/decorate.vue b/src/views/mall/promotion/diy/template/decorate.vue new file mode 100644 index 00000000..9c7500b2 --- /dev/null +++ b/src/views/mall/promotion/diy/template/decorate.vue @@ -0,0 +1,134 @@ +<template> + <DiyEditor + v-if="formData && !formLoading" + v-model="formData.property" + :title="templateItems[selectedTemplateItem].name" + :libs="libs" + :show-tab-bar="selectedTemplateItem === 0" + :show-navigation-bar="selectedTemplateItem > 0" + @save="submitForm" + > + <template #toolBarLeft> + <el-radio-group + v-model="selectedTemplateItem" + class="h-full!" + @change="handleTemplateItemChange" + > + <el-tooltip v-for="(item, index) in templateItems" :key="index" :content="item.name"> + <el-radio-button :label="index"> + <Icon :icon="item.icon" :size="24" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </template> + </DiyEditor> +</template> +<script setup lang="ts"> +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { DiyComponentLibrary } from '@/components/DiyEditor/util' + +/** 装修模板表单 */ +defineOptions({ name: 'DiyTemplateDecorate' }) + +// 左上角工具栏操作按钮 +const selectedTemplateItem = ref(0) +const templateItems = reactive([ + { name: '基础设置', icon: 'ep:iphone' }, + { name: '首页', icon: 'ep:home-filled' }, + { name: '我的', icon: 'ep:user-filled' } +]) + +const message = useMessage() // 消息弹窗 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<DiyTemplateApi.DiyTemplateVO>() +const formRef = ref() // 表单 Ref + +// 获取详情 +const getPageDetail = async (id: any) => { + formLoading.value = true + try { + formData.value = await DiyTemplateApi.getDiyTemplate(id) + } finally { + formLoading.value = false + } +} + +// 模板组件库 +const templateLibs = [] as DiyComponentLibrary[] +// 页面组件库 +const pageLibs = [ + { + name: '基础组件', + extended: true, + components: [ + 'SearchBar', + 'NoticeBar', + 'GridNavigation', + 'ListNavigation', + 'Divider', + 'TitleBar' + ] + }, + { name: '图文组件', extended: true, components: ['Carousel'] }, + { name: '商品组件', extended: true, components: ['ProductCard'] }, + { + name: '会员组件', + extended: true, + components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard'] + }, + { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] } +] as DiyComponentLibrary[] +// 当前组件库 +const libs = ref<DiyComponentLibrary[]>(templateLibs) +const handleTemplateItemChange = () => { + if (selectedTemplateItem.value === 0) { + libs.value = templateLibs + } else { + libs.value = pageLibs + } +} + +// 提交表单 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + // 提交请求 + formLoading.value = true + try { + await DiyTemplateApi.updateDiyTemplate(unref(formData)!) + message.success('保存成功') + } finally { + formLoading.value = false + } +} + +// 重置表单 +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + used: false, + usedTime: undefined, + remark: '', + previewImageUrls: [], + property: '' + } as DiyTemplateApi.DiyTemplateVO + formRef.value?.resetFields() +} + +/** 初始化 **/ +const { currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 +const route = useRoute() +onMounted(() => { + resetForm() + if (!route.params.id) { + message.warning('参数错误,页面编号不能为空!') + delView(unref(currentRoute)) + return + } + getPageDetail(route.params.id) +}) +</script> diff --git a/src/views/mall/promotion/diy/template/index.vue b/src/views/mall/promotion/diy/template/index.vue new file mode 100644 index 00000000..dcf13698 --- /dev/null +++ b/src/views/mall/promotion/diy/template/index.vue @@ -0,0 +1,225 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="模板名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模板名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:diy-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="预览图" align="center" prop="previewImageUrls"> + <template #default="scope"> + <el-image + class="h-40px max-w-40px" + v-for="(url, index) in scope.row.previewImageUrls" + :key="index" + :src="url" + :preview-src-list="scope.row.previewImageUrls" + :initial-index="index" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="模板名称" align="center" prop="name" /> + <el-table-column label="是否使用" align="center" prop="used"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.used" /> + </template> + </el-table-column> + <el-table-column + label="使用时间" + align="center" + prop="usedTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="200"> + <template #default="scope"> + <el-button + link + type="primary" + @click="handleDecorate(scope.row.id)" + v-hasPermi="['promotion:diy-template:update']" + > + 装修 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:diy-template:update']" + > + 编辑 + </el-button> + <template v-if="!scope.row.used"> + <el-button + link + type="primary" + @click="handleUse(scope.row)" + v-hasPermi="['promotion:diy-template:use']" + > + 使用 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['promotion:diy-template:delete']" + > + 删除 + </el-button> + </template> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DiyTemplateForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' +import DiyTemplateForm from './DiyTemplateForm.vue' +import { DICT_TYPE } from '@/utils/dict' + +/** 装修模板 */ +defineOptions({ name: 'DiyTemplate' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiyTemplateApi.getDiyTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiyTemplateApi.deleteDiyTemplate(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 使用模板 */ +const handleUse = async (row: DiyTemplateApi.DiyTemplateVO) => { + try { + // 使用模板的二次确认 + await message.confirm(`是否使用模板“${row.name}”?`) + // 发起删除 + await DiyTemplateApi.useDiyTemplate(row.id) + message.success('使用成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 打开装修页面 */ +const { push } = useRouter() +const handleDecorate = (id: number) => { + push({ name: 'DiyTemplateDecorate', params: { id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script>