feat:【antd】【mall】diy-editor 的整体继续迁移

This commit is contained in:
YunaiV
2025-11-03 23:53:31 +08:00
parent 06309e40b8
commit fde4b7852c
12 changed files with 382 additions and 275 deletions

View File

@@ -37,9 +37,12 @@ const detailSelectDialog = ref<{
type: undefined,
}); // 详情选择对话框
/** 打开弹窗 */
const dialogVisible = ref(false);
const open = (link: string) => {
defineExpose({ open });
/** 打开弹窗 */
async function open(link: string) {
activeAppLink.value.path = link;
dialogVisible.value = true;
// 滚动到当前的链接
@@ -54,19 +57,18 @@ const open = (link: string) => {
);
if (group) {
// 使用 nextTick 的原因:可能 Dom 还没生成,导致滚动失败
nextTick(() => handleGroupSelected(group.name));
await nextTick();
handleGroupSelected(group.name);
}
};
defineExpose({ open });
}
/** 处理 APP 链接选中 */
const handleAppLinkSelected = (appLink: AppLink) => {
function handleAppLinkSelected(appLink: AppLink) {
if (!isSameLink(appLink.path, activeAppLink.value.path)) {
activeAppLink.value = appLink;
}
switch (appLink.type) {
case APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST: {
detailSelectDialog.value.visible = true;
detailSelectDialog.value.type = appLink.type;
// 返显
detailSelectDialog.value.id =
@@ -74,26 +76,30 @@ const handleAppLinkSelected = (appLink: AppLink) => {
'id',
`http://127.0.0.1${activeAppLink.value.path}`,
) || undefined;
detailSelectDialog.value.visible = true;
break;
}
default: {
break;
}
}
};
}
/** 处理确认提交 */
function handleSubmit() {
dialogVisible.value = false;
emit('change', activeAppLink.value.path);
emit('appLinkChange', activeAppLink.value);
dialogVisible.value = false;
}
/**
* 处理右侧链接列表滚动
*
* @param {object} param0 滚动事件参数
* @param {number} param0.scrollTop 滚动条的位置
*/
function handleScroll({ scrollTop }: { scrollTop: number }) {
function handleScroll(event: Event) {
const scrollTop = (event.target as HTMLDivElement).scrollTop;
const titleEl = groupTitleRefs.value.find((titleEl: HTMLInputElement) => {
// 获取标题的位置信息
const { offsetHeight, offsetTop } = titleEl;
@@ -137,47 +143,59 @@ function isSameLink(link1: string, link2: string) {
/** 处理详情选择 */
function handleProductCategorySelected(id: number) {
// TODO @AI这里有点问题activeAppLink 地址;
// 生成 activeAppLink
const url = new URL(activeAppLink.value.path, 'http://127.0.0.1');
// 修改 id 参数
url.searchParams.set('id', `${id}`);
// 排除域名
activeAppLink.value.path = `${url.pathname}${url.search}`;
// 关闭对话框
// 关闭对话框,并重置 id
detailSelectDialog.value.visible = false;
// 重置 id
detailSelectDialog.value.id = undefined;
}
</script>
<template>
<Modal v-model:open="dialogVisible" title="选择链接" width="65%">
<Modal
v-model:open="dialogVisible"
title="选择链接"
width="65%"
@ok="handleSubmit"
>
<div class="flex h-[500px] gap-2">
<!-- 左侧分组列表 -->
<div class="flex h-full flex-col overflow-y-auto" ref="groupScrollbar">
<Button
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
class="mb-1 ml-0 mr-4 w-[90px] justify-start"
:class="[{ active: activeGroup === group.name }]"
ref="groupBtnRefs"
:type="activeGroup === group.name ? 'primary' : 'default'"
@click="handleGroupSelected(group.name)"
<div class="flex flex-col">
<!-- 左侧分组列表 -->
<div
class="h-full overflow-y-auto border-r border-gray-200 pr-2"
ref="groupScrollbar"
>
{{ group.name }}
</Button>
<Button
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
class="!ml-0 mb-1 mr-4 !justify-start"
:class="[{ active: activeGroup === group.name }]"
ref="groupBtnRefs"
:type="activeGroup === group.name ? 'primary' : 'default'"
:ghost="activeGroup !== group.name"
@click="handleGroupSelected(group.name)"
>
{{ group.name }}
</Button>
</div>
</div>
<!-- 右侧链接列表 -->
<div
class="h-full flex-1 overflow-y-auto"
class="h-full flex-1 overflow-y-auto pl-2"
@scroll="handleScroll"
ref="linkScrollbar"
>
<div
v-for="(group, groupIndex) in APP_LINK_GROUP_LIST"
:key="groupIndex"
class="mb-4 border-b border-gray-100 pb-4 last:mb-0 last:border-b-0"
>
<!-- 分组标题 -->
<div class="font-bold" ref="groupTitleRefs">{{ group.name }}</div>
<div class="mb-2 font-bold" ref="groupTitleRefs">
{{ group.name }}
</div>
<!-- 链接列表 -->
<Tooltip
v-for="(appLink, appLinkIndex) in group.links"
@@ -201,13 +219,9 @@ function handleProductCategorySelected(id: number) {
</div>
</div>
</div>
<!-- 底部对话框操作按钮 -->
<template #footer>
<Button type="primary" @click="handleSubmit"> </Button>
<Button @click="dialogVisible = false"> </Button>
</template>
</Modal>
<Modal v-model:open="detailSelectDialog.visible" title="" width="50%">
<Modal v-model:open="detailSelectDialog.visible" title="选择分类" width="65%">
<Form class="min-h-[200px]">
<FormItem
label="选择分类"
@@ -224,6 +238,7 @@ function handleProductCategorySelected(id: number) {
</Form>
</Modal>
</template>
<style lang="scss" scoped>
:deep(.ant-btn + .ant-btn) {
margin-left: 0 !important;

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Button, Input, InputGroup } from 'ant-design-vue';
import { Button, Input } from 'ant-design-vue';
import AppLinkSelectDialog from './app-link-select-dialog.vue';
/** APP 链接输入框 */
defineOptions({ name: 'AppLinkInput' });
// 定义属性
/** 定义属性 */
const props = defineProps({
modelValue: {
type: String,
@@ -23,8 +23,16 @@ const emit = defineEmits<{
const dialogRef = ref(); // 选择对话框
const appLink = ref(''); // 当前的链接
const handleOpenDialog = () => dialogRef.value?.open(appLink.value); // 处理打开对话框
const handleLinkSelected = (link: string) => (appLink.value = link); // 处理 APP 链接选中
/** 处理打开对话框 */
function handleOpenDialog() {
return dialogRef.value?.open(appLink.value);
}
/** 处理 APP 链接选中 */
function handleLinkSelected(link: string) {
appLink.value = link;
}
watch(
() => props.modelValue,
@@ -38,14 +46,19 @@ watch(
);
</script>
<template>
<InputGroup compact>
<Input
v-model:value="appLink"
placeholder="输入或选择链接"
class="flex-1"
/>
<Button @click="handleOpenDialog">选择</Button>
</InputGroup>
<Input v-model:value="appLink" placeholder="输入或选择链接">
<template #addonAfter>
<Button @click="handleOpenDialog" class="!border-none">选择</Button>
</template>
</Input>
<AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" />
</template>
<style scoped lang="scss">
:deep(.ant-input-group-addon) {
padding: 0;
background: transparent;
border: 0;
}
</style>

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
// import { PREDEFINE_COLORS } from '@vben/constants';
import { Input, InputGroup } from 'ant-design-vue';
import { Input } from 'ant-design-vue';
/** 颜色输入框 */
defineOptions({ name: 'ColorInput' });
@@ -28,12 +26,25 @@ const color = computed({
</script>
<template>
<InputGroup compact>
<!-- TODO 芋艿后续在处理antd 不支持该组件
<ColorPicker v-model:value="color" :presets="PREDEFINE_COLORS" />
-->
<Input v-model:value="color" class="flex-1" />
</InputGroup>
<div class="flex gap-2">
<input
v-model="color"
type="color"
class="h-8 w-12 cursor-pointer rounded border border-gray-300"
/>
<Input v-model:value="color" class="flex-1" placeholder="请输入颜色值" />
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
input[type='color'] {
&::-webkit-color-swatch-wrapper {
padding: 2px;
}
&::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
}
</style>

View File

@@ -131,13 +131,9 @@ const handleSliderChange = (prop: string) => {
</TabPane>
<!-- 每个组件的通用内容 -->
<TabPane tab="样式" key="style">
<TabPane tab="样式" key="style" force-render>
<Card title="组件样式" class="property-group">
<Form
:model="formData"
label-col="{ span: 6 }"
wrapper-col="{ span: 18 }"
>
<Form :model="formData">
<FormItem label="组件背景" name="bgType">
<RadioGroup v-model:value="formData.bgType">
<Radio value="color">纯色</Radio>
@@ -160,24 +156,22 @@ const handleSliderChange = (prop: string) => {
<template #tip>建议宽度 750px</template>
</UploadImg>
</FormItem>
<Tree
:tree-data="treeData"
:expand-on-click-node="false"
default-expand-all
>
<template #title="{ data, node }">
<Tree :tree-data="treeData" default-expand-all>
<template #title="{ dataRef }">
<FormItem
:label="data.label"
:name="data.prop"
:label="dataRef.label"
:name="dataRef.prop"
:label-col="dataRef.children ? { span: 6 } : { span: 5, offset: 1 }"
:wrapper-col="dataRef.children ? { span: 18 } : { span: 18 }"
class="mb-0 w-full"
>
<Slider
v-model:value="
formData[data.prop as keyof ComponentStyle] as number
formData[dataRef.prop as keyof ComponentStyle] as number
"
:max="100"
:min="0"
@change="handleSliderChange(data.prop)"
@change="handleSliderChange(dataRef.prop)"
/>
</FormItem>
</template>
@@ -197,4 +191,14 @@ const handleSliderChange = (prop: string) => {
:deep(.ant-input-number) {
width: 50px;
}
:deep(.ant-tree) {
.ant-tree-node-content-wrapper {
flex: 1;
}
.ant-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -49,9 +49,7 @@ const emits = defineEmits<{
type DiyComponentWithStyle = DiyComponent<any> & {
property: { style?: ComponentStyle };
};
/**
* 组件样式
*/
/** 组件样式 */
const style = computed(() => {
const componentStyle = props.component.property.style;
if (!componentStyle) {
@@ -78,38 +76,27 @@ const style = computed(() => {
};
});
/**
* 移动组件
* @param direction 移动方向
*/
/** 移动组件 */
const handleMoveComponent = (direction: number) => {
emits('move', direction);
};
/**
* 复制组件
*/
/** 复制组件 */
const handleCopyComponent = () => {
emits('copy');
};
/**
* 删除组件
*/
/** 删除组件 */
const handleDeleteComponent = () => {
emits('delete');
};
</script>
<template>
<div class="component" :class="[{ active }]">
<div
:style="{
...style,
}"
>
<div class="component relative cursor-move" :class="[{ active }]">
<div :style="style">
<component :is="component.id" :property="component.property" />
</div>
<div class="component-wrap">
<div class="component-wrap absolute left-[-2px] top-0 block h-full w-full">
<!-- 左侧组件名悬浮的小贴条 -->
<div class="component-name" v-if="component.name">
{{ component.name }}
@@ -158,19 +145,8 @@ $hover-border-width: 1px;
$name-position: -85px;
$toolbar-position: -55px;
/* 组件 */
.component {
position: relative;
cursor: move;
.component-wrap {
position: absolute;
top: 0;
left: -$active-border-width;
display: block;
width: 100%;
height: 100%;
/* 鼠标放到组件上时 */
&:hover {
border: $hover-border-width dashed var(--ant-color-primary);
@@ -236,7 +212,7 @@ $toolbar-position: -55px;
}
}
/* 组件选中时 */
/* 选中状态 */
&.active {
margin-bottom: 4px;

View File

@@ -61,7 +61,7 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
</script>
<template>
<div
<aside
class="editor-left z-[1] w-[261px] shrink-0 select-none shadow-[8px_0_8px_-8px_rgb(0_0_0/0.12)]"
>
<div class="h-full overflow-y-auto">
@@ -104,7 +104,7 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
</CollapsePanel>
</Collapse>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">

View File

@@ -1,4 +1,119 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import type { SearchProperty } from './config';
import { watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isString } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
Card,
Form,
FormItem,
Input,
RadioButton,
RadioGroup,
Slider,
Switch,
Tooltip,
} from 'ant-design-vue';
import { ColorInput, Draggable } from '#/views/mall/promotion/components';
import ComponentContainerProperty from '../../component-container-property.vue';
/** 搜索框属性面板 */
defineOptions({ name: 'SearchProperty' });
const props = defineProps<{ modelValue: SearchProperty }>();
const emit = defineEmits(['update:modelValue']);
const formData = useVModel(props, 'modelValue', emit);
/** 监听热词数组变化 */
watch(
() => formData.value.hotKeywords,
(newVal) => {
// 找到非字符串项的索引
const nonStringIndex = newVal.findIndex((item) => !isString(item));
if (nonStringIndex !== -1) {
formData.value.hotKeywords[nonStringIndex] = '';
}
},
{ deep: true, flush: 'post' },
);
</script>
<template><Page>待完成</Page></template>
<template>
<ComponentContainerProperty v-model="formData.style">
<Form :model="formData">
<Card title="搜索热词" class="property-group">
<Draggable
v-model="formData.hotKeywords"
:empty-item="{
type: 'input',
placeholder: '请输入热词',
}"
>
<template #default="{ index }">
<Input
v-model:value="formData.hotKeywords[index]"
placeholder="请输入热词"
/>
</template>
</Draggable>
</Card>
<Card title="搜索样式" class="property-group">
<FormItem label="框体样式">
<RadioGroup v-model:value="formData!.borderRadius">
<Tooltip title="方形" placement="top">
<RadioButton :value="0">
<IconifyIcon icon="tabler:input-search" />
</RadioButton>
</Tooltip>
<Tooltip title="圆形" placement="top">
<RadioButton :value="10">
<IconifyIcon icon="iconoir:input-search" />
</RadioButton>
</Tooltip>
</RadioGroup>
</FormItem>
<FormItem label="提示文字" name="placeholder">
<Input v-model:value="formData.placeholder" />
</FormItem>
<FormItem label="文本位置" name="placeholderPosition">
<RadioGroup v-model:value="formData!.placeholderPosition">
<Tooltip title="居左" placement="top">
<RadioButton value="left">
<IconifyIcon icon="ant-design:align-left-outlined" />
</RadioButton>
</Tooltip>
<Tooltip title="居中" placement="top">
<RadioButton value="center">
<IconifyIcon icon="ant-design:align-center-outlined" />
</RadioButton>
</Tooltip>
</RadioGroup>
</FormItem>
<FormItem label="扫一扫" name="showScan">
<Switch v-model:checked="formData!.showScan" />
</FormItem>
<FormItem label="框体高度" name="height">
<Slider
v-model:value="formData!.height"
:max="50"
:min="28"
/>
</FormItem>
<FormItem label="框体颜色" name="backgroundColor">
<ColorInput v-model="formData.backgroundColor" />
</FormItem>
<FormItem label="文本颜色" name="textColor">
<ColorInput v-model="formData.textColor" />
</FormItem>
</Card>
</Form>
</ComponentContainerProperty>
</template>

View File

@@ -232,7 +232,6 @@ function handleCopyComponent(index: number) {
/** 删除组件 */
function handleDeleteComponent(index: number) {
// 删除组件
pageComponents.value.splice(index, 1);
if (index < pageComponents.value.length) {
// 1. 不是最后一个组件时,删除后选中下面的组件
@@ -325,7 +324,7 @@ onMounted(() => {
</div>
<!-- 中心区域 -->
<div class="editor-container flex flex-1">
<div class="editor-container h-[calc(100vh-135px)]">
<!-- 左侧组件库ComponentLibrary -->
<ComponentLibrary
v-if="libs && libs.length > 0"
@@ -333,11 +332,15 @@ onMounted(() => {
:list="libs"
/>
<!-- 中心设计区域ComponentContainer -->
<div class="editor-center page-prop-area" @click="handlePageSelected">
<div
class="editor-center page-prop-area relative mt-4 flex w-full flex-1 flex-col justify-center overflow-hidden"
:style="{ backgroundColor: 'var(--app-content-bg-color)' }"
@click="handlePageSelected"
>
<!-- 手机顶部 -->
<div class="editor-design-top">
<div class="editor-design-top mx-auto flex w-[375px] flex-col">
<!-- 手机顶部状态栏 -->
<img alt="" class="status-bar" :src="statusBarImg" />
<img alt="" class="h-5 w-[375px] bg-white" :src="statusBarImg" />
<!-- 手机顶部导航栏 -->
<ComponentContainer
v-if="showNavigationBar"
@@ -365,45 +368,46 @@ onMounted(() => {
</div>
<!-- 手机页面编辑区域 -->
<div
class="editor-design-center page-prop-area phone-container overflow-y-auto"
class="editor-design-center page-prop-area h-full w-full overflow-y-auto"
:style="{
backgroundColor: pageConfigComponent.property.backgroundColor,
backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`,
height: 'calc(100vh - 135px - 120px)',
}"
>
<draggable
v-model="pageComponents"
:animation="200"
:force-fallback="true"
class="page-prop-area drag-area"
filter=".component-toolbar"
ghost-class="draggable-ghost"
group="component"
item-key="index"
@change="handleComponentChange"
>
<template #item="{ element, index }">
<ComponentContainer
v-if="!element.position || element.position === 'center'"
:active="selectedComponentIndex === index"
:can-move-down="index < pageComponents.length - 1"
:can-move-up="index > 0"
:component="element"
@click="handleComponentSelected(element, index)"
@copy="handleCopyComponent(index)"
@delete="handleDeleteComponent(index)"
@move="
(direction: number) => handleMoveComponent(index, direction)
"
/>
</template>
</draggable>
<div class="phone-container">
<draggable
v-model="pageComponents"
:animation="200"
:force-fallback="false"
class="page-prop-area drag-area"
filter=".component-toolbar"
ghost-class="draggable-ghost"
group="component"
item-key="index"
@change="handleComponentChange"
>
<template #item="{ element, index }">
<ComponentContainer
v-if="!element.position || element.position === 'center'"
:active="selectedComponentIndex === index"
:can-move-down="index < pageComponents.length - 1"
:can-move-up="index > 0"
:component="element"
@click="handleComponentSelected(element, index)"
@copy="handleCopyComponent(index)"
@delete="handleDeleteComponent(index)"
@move="
(direction: number) => handleMoveComponent(index, direction)
"
/>
</template>
</draggable>
</div>
</div>
<!-- 手机底部导航 -->
<div
v-if="showTabBar"
class="editor-design-bottom component cursor-pointer"
class="editor-design-bottom component mx-auto w-[375px] cursor-pointer"
>
<ComponentContainer
:active="selectedComponent?.id === tabBarComponent.id"
@@ -413,7 +417,9 @@ onMounted(() => {
/>
</div>
<!-- 固定布局的组件 操作按钮区 -->
<div class="fixed-component-action-group gap-2">
<div
class="fixed-component-action-group absolute right-4 top-0 flex flex-col gap-2"
>
<Tag
v-if="showPageConfig"
:color="
@@ -422,6 +428,7 @@ onMounted(() => {
: 'default'
"
class="cursor-pointer"
size="large"
@click="handleComponentSelected(pageConfigComponent)"
>
<IconifyIcon :icon="pageConfigComponent.icon" :size="12" />
@@ -435,6 +442,7 @@ onMounted(() => {
"
closable
class="cursor-pointer"
size="large"
@click="handleComponentSelected(component)"
@close="handleDeleteComponent(index)"
>
@@ -445,11 +453,11 @@ onMounted(() => {
</div>
</div>
<!-- 右侧属性面板ComponentContainerProperty -->
<div v-if="selectedComponent?.property" class="editor-right w-[350px]">
<Card
class="h-full"
:body-style="{ height: 'calc(100% - 57px)', padding: 0 }"
>
<aside
v-if="selectedComponent?.property"
class="editor-right w-[350px] shrink-0 overflow-hidden shadow-[-8px_0_8px_-8px_rgb(0_0_0/0.12)]"
>
<Card class="h-full" :body-style="{ padding: 0, height: 'calc(100% - 57px)' }">
<!-- 组件名称 -->
<template #title>
<div class="flex items-center gap-2">
@@ -465,7 +473,7 @@ onMounted(() => {
/>
</div>
</Card>
</div>
</aside>
</div>
</div>
@@ -524,14 +532,10 @@ $phone-width: 375px;
/* 中心操作区 */
.editor-container {
height: calc(100vh - 135px);
display: flex;
/* 右侧属性面板 */
:deep(.editor-right) {
flex-shrink: 0;
overflow: hidden;
box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%);
/* 属性面板顶部:减少内边距 */
:deep(.ant-card-head) {
padding: 8px 16px;
@@ -560,37 +564,6 @@ $phone-width: 375px;
/* 中心区域 */
.editor-center {
position: relative;
display: flex;
flex: 1 1 0;
flex-direction: column;
justify-content: center;
width: 100%;
margin: 16px 0 0;
overflow: hidden;
background-color: var(--app-content-bg-color);
/* 手机顶部 */
.editor-design-top {
display: flex;
flex-direction: column;
width: $phone-width;
margin: 0 auto;
/* 手机顶部状态栏 */
.status-bar {
width: $phone-width;
height: 20px;
background-color: #fff;
}
}
/* 手机底部导航 */
.editor-design-bottom {
width: $phone-width;
margin: 0 auto;
}
/* 手机页面编辑区域 */
:deep(.editor-design-center) {
width: 100%;
@@ -613,21 +586,11 @@ $phone-width: 375px;
/* 固定布局的组件 操作按钮区 */
.fixed-component-action-group {
position: absolute;
top: 0;
right: 16px;
display: flex;
flex-direction: column;
:deep(.ant-tag) {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
border: none;
box-shadow: 0 2px 8px 0 rgb(0 0 0 / 10%);
.anticon {
.ant-tag-icon {
margin-right: 4px;
}
}

View File

@@ -45,7 +45,7 @@ const handleDelete = function (index: number) {
</script>
<template>
<div class="text-xs text-gray-500">拖动左上角的小圆点可对其排序</div>
<div class="text-sm text-gray-500">拖动左上角的小圆点可对其排序</div>
<VueDraggable
:list="formData"
:force-fallback="true"
@@ -58,21 +58,19 @@ const handleDelete = function (index: number) {
<div class="mb-1 flex flex-col gap-1 rounded border border-gray-200 p-2">
<!-- 操作按钮区 -->
<div
class="-m-2 mb-1 flex flex-row items-center justify-between p-2"
style="background-color: var(--app-content-bg-color)"
class="-m-2 mb-1 flex flex-row items-center justify-between rounded-t p-2"
style="background-color: var(--ant-color-bg-container-secondary)"
>
<Tooltip title="拖动排序">
<IconifyIcon
icon="ic:round-drag-indicator"
class="drag-icon cursor-move"
style="color: #8a909c"
class="drag-icon cursor-move text-gray-500"
/>
</Tooltip>
<Tooltip title="删除">
<Tooltip v-if="formData.length > min" title="删除">
<IconifyIcon
icon="ep:delete"
class="cursor-pointer text-red-500"
v-if="formData.length > min"
class="cursor-pointer text-red-500 hover:text-red-600"
@click="handleDelete(index)"
/>
</Tooltip>
@@ -82,7 +80,7 @@ const handleDelete = function (index: number) {
</div>
</template>
</VueDraggable>
<Tooltip :title="limit < 1 ? undefined : `最多添加${limit}个`">
<Tooltip :title="limit > 0 && limit < Number.MAX_VALUE ? `最多添加${limit}个` : undefined">
<Button
type="primary"
ghost
@@ -90,7 +88,10 @@ const handleDelete = function (index: number) {
:disabled="limit > 0 && formData.length >= limit"
@click="handleAdd"
>
<IconifyIcon icon="ep:plus" /><span>添加</span>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
添加
</Button>
</Tooltip>
</template>

View File

@@ -1,8 +1,6 @@
<script lang="ts" setup>
// import { PREDEFINE_COLORS } from '@vben/constants';
import { useVModels } from '@vueuse/core';
import { Input, InputGroup } from 'ant-design-vue';
import { Input } from 'ant-design-vue';
/** 带颜色选择器输入框 */
defineOptions({ name: 'InputWithColor' });
@@ -24,11 +22,25 @@ const { modelValue, color } = useVModels(props, emit);
</script>
<template>
<InputGroup compact>
<div class="flex gap-2">
<Input v-model:value="modelValue" v-bind="$attrs" class="flex-1" />
<!-- TODO 芋艿后续在处理antd 不支持该组件
<ColorPicker v-model:value="color" :presets="PREDEFINE_COLORS" />
-->
</InputGroup>
<input
v-model="color"
type="color"
class="h-8 w-10 cursor-pointer rounded border border-gray-300"
/>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
input[type='color'] {
&::-webkit-color-swatch-wrapper {
padding: 2px;
}
&::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
}
</style>

View File

@@ -7,16 +7,16 @@ import { IconifyIcon } from '@vben/icons';
import { createRect, isContains, isOverlap } from './util';
// TODO @AI: 改成标准注释
// 魔方编辑器
// 有两部分组成:
// 1. 魔方矩阵:位于底层,由方块组件的二维表格,用于创建热区
// 操作方法:
// 1.1 点击其中一个方块就会进入热区选择模式
// 1.2 再次点击另外一个方块时,结束热区选择模式
// 1.3 在两个方块中间的区域创建热区
// 如果两次点击的都是同一方块,就只创建一个格子的热区
// 2. 热区:位于顶层,采用绝对定位,覆盖在魔方矩阵上面。
/**
* 魔方编辑器,有两部分组成:
* 1. 魔方矩阵:位于底层,由方块组件的二维表格,用于创建热区
* 操作方法:
* 1.1 点击其中一个方块就会进入热区选择模式
* 1.2 再次点击另外一个方块时,结束热区选择模式
* 1.3 在两个方块中间的区域创建热区
* 如果两次点击的都是同一方块,就只创建一个格子的热区
* 2. 热区:位于顶层,采用绝对定位,覆盖在魔方矩阵上面。
*/
defineOptions({ name: 'MagicCubeEditor' });
/** 定义属性 */
@@ -34,7 +34,6 @@ const props = defineProps({
type: Number,
default: 4,
}, // 列数,默认 4 列
cubeSize: {
type: Number,
default: 75,
@@ -70,6 +69,7 @@ watch(
);
const hotAreas = ref<Rect[]>([]); // 热区列表
/** 初始化热区 */
watch(
() => props.modelValue,
@@ -86,20 +86,20 @@ const isHotAreaSelectMode = () => !!hotAreaBeginCube.value; // 是否开启了
* @param currentRow 当前行号
* @param currentCol 当前列号
*/
const handleCubeClick = (currentRow: number, currentCol: number) => {
function handleCubeClick(currentRow: number, currentCol: number) {
const currentCube = cubes.value[currentRow]?.[currentCol];
if (!currentCube) {
return;
}
// 情况1进入热区选择模式
// 情况 1进入热区选择模式
if (!isHotAreaSelectMode()) {
hotAreaBeginCube.value = currentCube;
hotAreaBeginCube.value!.active = true;
return;
}
// 情况2结束热区选择模式
// 情况 2结束热区选择模式
hotAreas.value.push(createRect(hotAreaBeginCube.value!, currentCube));
// 结束热区选择模式
exitHotAreaSelectMode();
@@ -111,7 +111,7 @@ const handleCubeClick = (currentRow: number, currentCol: number) => {
}
// 发送热区变动通知
emitUpdateModelValue();
};
}
/**
* 处理鼠标经过方块
@@ -119,7 +119,7 @@ const handleCubeClick = (currentRow: number, currentCol: number) => {
* @param currentRow 当前行号
* @param currentCol 当前列号
*/
const handleCellHover = (currentRow: number, currentCol: number) => {
function handleCellHover(currentRow: number, currentCol: number) {
// 当前没有进入热区选择模式
if (!isHotAreaSelectMode()) {
return;
@@ -138,7 +138,6 @@ const handleCellHover = (currentRow: number, currentCol: number) => {
if (isOverlap(hotArea, currentSelectedArea)) {
// 结束热区选择模式
exitHotAreaSelectMode();
return;
}
}
@@ -147,13 +146,9 @@ const handleCellHover = (currentRow: number, currentCol: number) => {
eachCube((_, __, cube) => {
cube.active = isContains(currentSelectedArea, cube);
});
};
}
/**
* 处理热区删除
*
* @param index 热区索引
*/
/** 处理热区删除 */
function handleDeleteHotArea(index: number) {
hotAreas.value.splice(index, 1);
// 结束热区选择模式
@@ -165,10 +160,12 @@ function handleDeleteHotArea(index: number) {
const emitUpdateModelValue = () => emit('update:modelValue', hotAreas.value); // 发送热区变动通知
const selectedHotAreaIndex = ref(0); // 热区选中
const handleHotAreaSelected = (hotArea: Rect, index: number) => {
/** 处理热区选中 */
function handleHotAreaSelected(hotArea: Rect, index: number) {
selectedHotAreaIndex.value = index;
emit('hotAreaSelected', hotArea, index);
};
}
/**
* 结束热区选择模式
@@ -189,7 +186,7 @@ function exitHotAreaSelectMode() {
* 迭代魔方矩阵
* @param callback 回调
*/
const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
function eachCube(callback: (x: number, y: number, cube: Cube) => void) {
for (const [x, row] of cubes.value.entries()) {
if (!row) continue;
for (const [y, cube] of row.entries()) {
@@ -198,7 +195,7 @@ const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
}
}
}
};
}
</script>
<template>
<div class="relative">
@@ -261,13 +258,14 @@ const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
.cube {
box-sizing: border-box;
color: var(--el-text-color-secondary);
color: var(--ant-color-text-secondary);
text-align: center;
line-height: 1;
cursor: pointer;
border: 1px solid var(--el-border-color);
border: 1px solid var(--ant-color-border);
&.active {
background: var(--el-color-primary-light-9);
background: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
}
}
@@ -277,12 +275,12 @@ const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary);
color: var(--ant-color-primary);
cursor: pointer;
border-spacing: 0;
border-collapse: collapse;
background: var(--el-color-primary-light-8);
border: 1px solid var(--el-color-primary);
background: color-mix(in srgb, var(--ant-color-primary) 20%, transparent);
border: 1px solid var(--ant-color-primary);
.btn-delete {
position: absolute;
@@ -294,7 +292,7 @@ const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
justify-content: center;
width: 16px;
height: 16px;
background-color: #fff;
background-color: var(--ant-color-bg-container);
border-radius: 50%;
}
}

View File

@@ -1,51 +1,47 @@
// 坐标点
/** 坐标点 */
export interface Point {
x: number;
y: number;
}
// 矩形
/** 矩形 */
export interface Rect {
// 左上角 X 轴坐标
left: number;
// 左上Y 轴坐标
top: number;
// 右下角 X 轴坐标
right: number;
// 右下角 Y 轴坐标
bottom: number;
// 矩形宽度
width: number;
// 矩形高度
height: number;
left: number; // 左上角 X 轴坐标
top: number; // 左上角 Y 轴坐标
right: number; // 右下X 轴坐标
bottom: number; // 右下角 Y 轴坐标
width: number; // 矩形宽度
height: number; // 矩形高度
}
/**
* 判断两个矩形是否重叠
*
* @param a 矩形 A
* @param b 矩形 B
*/
export const isOverlap = (a: Rect, b: Rect): boolean => {
export function isOverlap(a: Rect, b: Rect): boolean {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.height + a.top > b.top
);
};
}
/**
* 检查坐标点是否在矩形内
* @param hotArea 矩形
* @param point 坐标
*/
export const isContains = (hotArea: Rect, point: Point): boolean => {
export function isContains(hotArea: Rect, point: Point): boolean {
return (
point.x >= hotArea.left &&
point.x < hotArea.right &&
point.y >= hotArea.top &&
point.y < hotArea.bottom
);
};
}
/**
* 在两个坐标点中间,创建一个矩形
@@ -59,14 +55,17 @@ export const isContains = (hotArea: Rect, point: Point): boolean => {
* @param a 坐标点一
* @param b 坐标点二
*/
export const createRect = (a: Point, b: Point): Rect => {
export function createRect(a: Point, b: Point): Rect {
// 计算矩形的范围
const [left, left2] = [a.x, b.x].sort();
const [top, top2] = [a.y, b.y].sort();
let [left, left2] = [a.x, b.x].sort();
left = left ?? 0;
left2 = left2 ?? 0;
let [top, top2] = [a.y, b.y].sort();
top = top ?? 0;
top2 = top2 ?? 0;
const right = left2 + 1;
const bottom = top2 + 1;
const height = bottom - top;
const width = right - left;
return { left, right, top, bottom, height, width };
};
}