merge
This commit is contained in:
GoCo 2025-09-21 16:25:24 +08:00
commit c7a66f623f
13 changed files with 4482 additions and 0 deletions

View File

@ -137,6 +137,19 @@ public class AiolExamController extends JeecgController<AiolExam, IAiolExamServi
return Result.OK("删除成功!");
}
//考试查询
@AutoLog(value = "考试列表查询")
@Operation(summary="考试-考试列表查询")
@GetMapping(value = "/queryExamList")
public Result<?> queryExamList(@RequestParam(name="examName",required=false) String examName,@RequestParam(name="type",required=true) String type) {
QueryWrapper<AiolExam> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("type",type);
if(!oConvertUtils.isEmpty(examName)){
queryWrapper.like("name",examName);
}
return Result.OK(examService.list(queryWrapper));
}
/**
* 批量删除
*

View File

@ -0,0 +1,539 @@
<template>
<div class="design-editor-fullscreen">
<!-- 顶部工具栏 -->
<div class="editor-header">
<div class="header-left">
<a-button @click="handleBack" type="text" size="small">
<template #icon>
<Icon icon="ion:arrow-back" />
</template>
返回
</a-button>
<a-divider type="vertical" />
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<a-space>
<a-button @click="handlePreview" size="small">
<template #icon>
<Icon icon="ion:eye-outline" />
</template>
预览
</a-button>
<a-button @click="handleSave" type="primary" size="small">
<template #icon>
<Icon icon="ion:save-outline" />
</template>
保存
</a-button>
</a-space>
</div>
</div>
<!-- 全屏主体内容区域 -->
<div class="editor-main">
<!-- 左侧悬停组件库容器 -->
<div
class="component-library-container"
@mouseenter="showComponentLibrary = true"
@mouseleave="handleLibraryMouseLeave"
>
<!-- 触发器 -->
<div class="component-library-trigger">
<div class="trigger-tab">
<Icon icon="ion:apps-outline" />
<span>组件库</span>
</div>
</div>
<!-- 组件库面板 -->
<div
class="component-library-panel"
:class="{ 'show': showComponentLibrary }"
>
<div class="library-header">
<h3>组件库</h3>
<a-button
type="text"
size="small"
@click="showComponentLibrary = false"
>
<Icon icon="ion:close" />
</a-button>
</div>
<div class="library-content">
<ComponentLibrary
@drag-start="handleDragStart"
@component-add="handleComponentAdd"
/>
</div>
</div>
</div>
<!-- 设计画布区域 -->
<div class="canvas-area">
<DesignCanvas
:components="pageComponents"
:selected-component="selectedComponent"
@component-select="handleComponentSelect"
@component-drop="handleComponentDrop"
@component-move="handleComponentMove"
@components-change="handleComponentsChange"
style="width: 100%; height: 100%;"
/>
</div>
<!-- 右侧属性面板 -->
<div
class="property-panel"
:class="{ 'show': selectedComponent }"
>
<div class="property-header">
<h3>属性设置</h3>
<a-button
type="text"
size="small"
@click="selectedComponent = null"
>
<Icon icon="ion:close" />
</a-button>
</div>
<div class="property-content">
<PropertyEditor
:component="selectedComponent"
@property-change="handlePropertyChange"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import ComponentLibrary from './components/ComponentLibrary.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import PropertyEditor from './components/PropertyEditor.vue';
const router = useRouter();
const route = useRoute();
const { createMessage } = useMessage();
const pageId = computed(() => route.query.id as string);
const pageTitle = ref('新建门户');
const selectedComponent = ref<any>(null);
const pageComponents = ref<any[]>([]);
const showComponentLibrary = ref(false);
function handleBack() {
router.push('/online-design/portal');
}
function handleLibraryMouseLeave() {
showComponentLibrary.value = false;
}
function handlePreview() {
if (pageId.value) {
const routeData = router.resolve(`/online-design/preview?id=${pageId.value}`);
window.open(routeData.href, '_blank');
} else {
createMessage.warning('请先保存页面');
}
}
function handleSave() {
const pageData = {
id: pageId.value || generateId(),
name: pageTitle.value,
components: pageComponents.value,
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
};
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const existingIndex = savedPages.findIndex(p => p.id === pageData.id);
if (existingIndex >= 0) {
savedPages[existingIndex] = pageData;
} else {
savedPages.push(pageData);
}
localStorage.setItem('online-design-pages', JSON.stringify(savedPages));
console.log('保存页面数据:', pageData);
createMessage.success('保存成功');
//
if (!pageId.value) {
router.replace(`/online-design/editor?id=${pageData.id}`);
}
}
function generateId() {
return 'page_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11);
}
function createComponent(type: string) {
const componentId = `${type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
const baseComponent = {
id: componentId,
type: type,
props: {},
data: {},
style: {},
isDragging: false,
};
//
switch (type) {
case 'carousel':
return {
...baseComponent,
props: {
autoplay: true,
dots: true,
},
data: {
images: [
{ url: 'https://via.placeholder.com/300x150/1890ff/white?text=轮播图1', alt: '轮播图1' },
{ url: 'https://via.placeholder.com/300x150/52c41a/white?text=轮播图2', alt: '轮播图2' },
{ url: 'https://via.placeholder.com/300x150/fa8c16/white?text=轮播图3', alt: '轮播图3' },
],
},
};
case 'text':
return {
...baseComponent,
props: {
content: '这是一段示例文本内容',
fontSize: '14px',
color: '#333',
textAlign: 'center',
},
};
case 'image':
return {
...baseComponent,
props: {
src: 'https://via.placeholder.com/300x150/1890ff/white?text=示例图片',
alt: '示例图片',
width: '100%',
},
};
case 'button':
return {
...baseComponent,
props: {
text: '按钮',
type: 'primary',
size: 'middle',
},
};
default:
return baseComponent;
}
}
function getComponentName(type: string) {
const nameMap = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return nameMap[type] || type;
}
function handleDragStart(componentType: string) {
console.log('开始拖拽组件:', componentType);
}
function handleComponentAdd(componentType: string) {
console.log('添加组件:', componentType);
//
const newComponent = createComponent(componentType);
pageComponents.value.push(newComponent);
selectedComponent.value = newComponent;
createMessage.success(`${getComponentName(componentType)}组件已添加`);
}
function handleComponentSelect(component: any) {
selectedComponent.value = component;
}
function handleComponentDrop(newComponent: any) {
console.log('组件放置:', newComponent);
pageComponents.value.push(newComponent);
selectedComponent.value = newComponent;
}
function handleComponentMove(event: any) {
console.log('组件移动:', event);
// TODO:
}
function handleComponentsChange(components: any[]) {
pageComponents.value = components;
}
function handlePropertyChange(updatedComponent: any) {
const index = pageComponents.value.findIndex(c => c.id === updatedComponent.id);
if (index !== -1) {
pageComponents.value[index] = updatedComponent;
selectedComponent.value = updatedComponent;
}
}
function loadPageData() {
if (pageId.value) {
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const pageData = savedPages.find((p: any) => p.id === pageId.value);
if (pageData) {
pageTitle.value = `编辑门户 - ${pageData.name}`;
pageComponents.value = pageData.components || [];
} else {
pageTitle.value = `编辑门户 - ${pageId.value}`;
pageComponents.value = [];
}
} else {
pageTitle.value = '新建门户';
pageComponents.value = [];
}
}
onMounted(() => {
loadPageData();
});
</script>
<style scoped>
.design-editor-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 确保在布局容器中也能全屏显示 */
:deep(.ant-layout-content) {
padding: 0 !important;
margin: 0 !important;
}
:deep(.main-content) {
padding: 0 !important;
margin: 0 !important;
}
.editor-header {
height: 48px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
}
.page-title {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
color: #333;
}
.editor-main {
flex: 1;
position: relative;
overflow: hidden;
}
/* 左侧组件库容器 */
.component-library-container {
position: fixed;
left: 0;
top: 48px;
bottom: 0;
z-index: 100;
pointer-events: none;
}
.component-library-container * {
pointer-events: auto;
}
/* 左侧组件库触发器 */
.component-library-trigger {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
}
.trigger-tab {
background: #1890ff;
color: white;
padding: 12px 8px;
border-radius: 0 8px 8px 0;
writing-mode: vertical-lr;
text-orientation: mixed;
font-size: 12px;
font-weight: 500;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.trigger-tab:hover {
background: #40a9ff;
transform: translateX(2px);
}
.trigger-tab span {
writing-mode: vertical-lr;
text-orientation: mixed;
}
/* 组件库面板 */
.component-library-panel {
position: absolute;
left: -320px;
top: 0;
bottom: 0;
width: 320px;
background: white;
border-right: 1px solid #e8e8e8;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15);
transition: left 0.3s ease;
display: flex;
flex-direction: column;
}
.component-library-panel.show {
left: 0;
}
.library-header {
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.library-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.library-content {
flex: 1;
overflow-y: auto;
}
/* 设计画布区域 */
.canvas-area {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transition: right 0.3s ease;
background: #f0f2f5;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 右侧属性面板 */
.property-panel {
position: fixed;
right: -360px;
top: 48px;
bottom: 0;
width: 360px;
background: white;
border-left: 1px solid #e8e8e8;
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
transition: right 0.3s ease;
z-index: 99;
display: flex;
flex-direction: column;
}
.property-panel.show {
right: 0;
}
.property-panel.show ~ .canvas-area {
right: 360px;
}
.property-header {
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.property-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.property-content {
flex: 1;
overflow-y: auto;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.component-library-panel {
width: 280px;
}
.property-panel {
width: 320px;
}
.property-panel.show ~ .canvas-area {
right: 320px;
}
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<div class="preview-container">
<!-- 预览工具栏 -->
<div class="preview-toolbar">
<div class="toolbar-left">
<a-button @click="handleBack" type="text">
<template #icon>
<Icon icon="ion:arrow-back" />
</template>
返回
</a-button>
<a-divider type="vertical" />
<span class="preview-title">{{ pageTitle }} - 预览</span>
</div>
<div class="toolbar-right">
<a-space>
<a-button @click="handleEdit">
<template #icon>
<Icon icon="ion:create-outline" />
</template>
编辑
</a-button>
<a-button @click="handleRefresh">
<template #icon>
<Icon icon="ion:refresh-outline" />
</template>
刷新
</a-button>
</a-space>
</div>
</div>
<!-- 预览内容区域 -->
<div class="preview-content">
<div class="preview-wrapper">
<div class="preview-page" :style="pageStyle">
<!-- 渲染页面组件 -->
<div
v-for="(component, index) in pageComponents"
:key="component.id || index"
class="preview-component"
:style="getComponentStyle(component)"
>
<component
:is="getComponentType(component.type)"
v-bind="component.props"
:data="component.data"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getComponentType as getDesignComponentType } from './components/design-components';
const router = useRouter();
const route = useRoute();
const { createMessage } = useMessage();
const pageId = computed(() => route.query.id as string);
const pageTitle = ref('门户预览');
const pageComponents = ref([]);
const pageStyle = ref({
minHeight: '100%',
background: '#ffffff',
});
function handleBack() {
router.push('/online-design/portal');
}
function handleEdit() {
router.push(`/online-design/editor?id=${pageId.value}`);
}
function handleRefresh() {
loadPageData();
createMessage.success('刷新成功');
}
function getComponentType(type: string) {
return getDesignComponentType(type);
}
function getComponentStyle(component: any) {
return {
position: 'relative',
width: component.style?.width || '100%',
height: component.style?.height || 'auto',
margin: component.style?.margin || '0',
padding: component.style?.padding || '0',
...component.style,
};
}
function loadPageData() {
// TODO:
if (pageId.value) {
//
pageTitle.value = `门户页面 ${pageId.value}`;
pageComponents.value = [
{
id: '1',
type: 'carousel',
props: {
autoplay: true,
dots: true,
},
data: {
images: [
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' },
],
},
style: {
width: '100%',
height: '300px',
marginBottom: '20px',
},
},
{
id: '2',
type: 'text',
props: {
tag: 'h1',
},
data: {
content: '欢迎来到我们的企业门户',
},
style: {
textAlign: 'center',
fontSize: '32px',
fontWeight: 'bold',
color: '#333',
marginBottom: '20px',
},
},
{
id: '3',
type: 'text',
props: {
tag: 'p',
},
data: {
content: '这是一个使用在线设计器创建的门户页面示例。您可以通过拖拽组件来快速构建美观的页面。',
},
style: {
textAlign: 'center',
fontSize: '16px',
color: '#666',
lineHeight: '1.6',
maxWidth: '800px',
margin: '0 auto 40px',
},
},
];
}
}
onMounted(() => {
loadPageData();
});
</script>
<style scoped>
.preview-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.preview-toolbar {
height: 60px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.toolbar-left {
display: flex;
align-items: center;
}
.preview-title {
font-size: 16px;
font-weight: 500;
margin-left: 10px;
}
.preview-content {
flex: 1;
overflow: auto;
padding: 20px;
}
.preview-wrapper {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.preview-page {
padding: 20px;
}
.preview-component {
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,328 @@
<template>
<div class="component-library">
<div class="library-header">
<h3>组件库</h3>
</div>
<div class="library-content">
<!-- 基础组件分类 -->
<div class="component-category">
<div class="category-title">
<Icon icon="ion:cube-outline" />
<span>基础组件</span>
</div>
<div class="component-list">
<!-- 轮播图组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'carousel')"
@dragend="handleDragEnd"
@click="handleAddComponent('carousel')"
>
<div class="component-icon">
<Icon icon="ion:images-outline" />
</div>
<div class="component-info">
<div class="component-name">轮播图</div>
<div class="component-desc">图片轮播展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 文本组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'text')"
@dragend="handleDragEnd"
@click="handleAddComponent('text')"
>
<div class="component-icon">
<Icon icon="ion:text-outline" />
</div>
<div class="component-info">
<div class="component-name">文本</div>
<div class="component-desc">文字内容展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 图片组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'image')"
@dragend="handleDragEnd"
@click="handleAddComponent('image')"
>
<div class="component-icon">
<Icon icon="ion:image-outline" />
</div>
<div class="component-info">
<div class="component-name">图片</div>
<div class="component-desc">单张图片展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 按钮组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'button')"
@dragend="handleDragEnd"
@click="handleAddComponent('button')"
>
<div class="component-icon">
<Icon icon="ion:radio-button-on-outline" />
</div>
<div class="component-info">
<div class="component-name">按钮</div>
<div class="component-desc">交互按钮</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
</div>
</div>
<!-- 布局组件分类 -->
<div class="component-category">
<div class="category-title">
<Icon icon="ion:grid-outline" />
<span>布局组件</span>
</div>
<div class="component-list">
<!-- 容器组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'container')"
@dragend="handleDragEnd"
>
<div class="component-icon">
<Icon icon="ion:square-outline" />
</div>
<div class="component-info">
<div class="component-name">容器</div>
<div class="component-desc">内容容器</div>
</div>
</div>
<!-- 分栏组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'columns')"
@dragend="handleDragEnd"
>
<div class="component-icon">
<Icon icon="ion:apps-outline" />
</div>
<div class="component-info">
<div class="component-name">分栏</div>
<div class="component-desc">多列布局</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon } from '/@/components/Icon';
const emit = defineEmits(['drag-start', 'drag-end', 'component-add']);
function handleDragStart(event: DragEvent, componentType: string) {
if (event.dataTransfer) {
//
event.dataTransfer.setData('component-type', componentType);
event.dataTransfer.effectAllowed = 'copy';
//
const dragImage = createDragImage(componentType);
event.dataTransfer.setDragImage(dragImage, 50, 25);
}
emit('drag-start', componentType);
}
function handleDragEnd() {
emit('drag-end');
}
function handleAddComponent(componentType: string) {
emit('component-add', componentType);
}
function createDragImage(componentType: string) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 100;
canvas.height = 50;
if (ctx) {
ctx.fillStyle = '#1890ff';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = 'white';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(getComponentName(componentType), 50, 30);
}
return canvas;
}
function getComponentName(type: string) {
const nameMap = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return nameMap[type] || type;
}
</script>
<style scoped>
.component-library {
height: 100%;
display: flex;
flex-direction: column;
}
.library-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.library-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.library-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.component-category {
margin-bottom: 24px;
}
.category-title {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: #666;
}
.category-title .anticon {
margin-right: 8px;
}
.component-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.component-item {
position: relative;
display: flex;
align-items: center;
padding: 12px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.component-item:hover {
background: #f0f9ff;
border-color: #1890ff;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.1);
}
.component-item:hover .add-button {
opacity: 1;
transform: scale(1);
}
.component-item:active {
cursor: grabbing;
}
.component-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 4px;
margin-right: 12px;
color: #1890ff;
font-size: 16px;
}
.component-info {
flex: 1;
}
.component-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.component-desc {
font-size: 12px;
color: #999;
}
.add-button {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%) scale(0.8);
width: 24px;
height: 24px;
background: #1890ff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0;
transition: all 0.2s;
cursor: pointer;
}
.add-button:hover {
background: #40a9ff;
transform: translateY(-50%) scale(1.1);
}
</style>

View File

@ -0,0 +1,831 @@
<template>
<div class="design-canvas">
<!-- 简化的工具栏 -->
<div class="canvas-toolbar">
<div class="toolbar-left">
<span class="canvas-title">设计画布</span>
</div>
<div class="toolbar-right">
<a-space size="small">
<!-- 网格控制 -->
<a-switch
v-model:checked="showGrid"
size="small"
/>
<span class="control-label">网格</span>
<!-- 缩放控制 -->
<a-button size="small" @click="handleZoomOut" :disabled="zoomLevel <= 0.5">
<Icon icon="ion:remove-outline" />
</a-button>
<span class="zoom-display">{{ Math.round(zoomLevel * 100) }}%</span>
<a-button size="small" @click="handleZoomIn" :disabled="zoomLevel >= 2">
<Icon icon="ion:add-outline" />
</a-button>
<a-button size="small" @click="handleZoomReset">
<Icon icon="ion:refresh-outline" />
</a-button>
<a-divider type="vertical" />
<a-button size="small" @click="handleClearAll" danger>
<Icon icon="ion:trash-outline" />
清空
</a-button>
</a-space>
</div>
</div>
<!-- 全屏画布容器 -->
<div class="canvas-container">
<div
class="canvas-content"
:class="{ 'drag-over': isDragOver, 'show-grid': showGrid }"
:style="canvasStyle"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<!-- 网格背景 -->
<div class="grid-background" v-if="showGrid"></div>
<!-- 空状态提示 -->
<div v-if="components.length === 0" class="empty-canvas">
<div class="empty-icon">
<Icon icon="ion:cube-outline" />
</div>
<div class="empty-text">
<p>从左侧组件库拖拽组件到网格中开始设计</p>
<p class="empty-subtext">支持网格布局和组件缩放</p>
</div>
</div>
<!-- 网格布局组件容器 -->
<div v-else class="components-grid">
<div
v-for="(element, index) in componentList"
:key="element.id"
class="component-item"
:class="{ 'selected': selectedComponent?.id === element.id, 'dragging': element.isDragging }"
:style="getComponentStyle(element, index)"
@click="handleComponentClick(element)"
@mousedown="handleMouseDown(element, $event)"
>
<!-- 组件工具栏 -->
<div class="component-toolbar" v-show="selectedComponent?.id === element.id">
<a-space size="small">
<a-button size="small" type="text" @click.stop="handleCopy(element)">
<Icon icon="ion:copy-outline" />
</a-button>
<a-button size="small" type="text" danger @click.stop="handleDelete(index)">
<Icon icon="ion:trash-outline" />
</a-button>
</a-space>
</div>
<!-- 组件内容 -->
<div class="component-content">
<component
:is="getComponentType(element.type)"
v-bind="element.props"
:data="element.data"
:design-mode="true"
/>
</div>
<!-- 缩放手柄 -->
<div
v-show="selectedComponent?.id === element.id"
class="resize-handles"
>
<div class="resize-handle resize-se" @mousedown.stop="handleResizeStart(element, $event, 'se')"></div>
<div class="resize-handle resize-sw" @mousedown.stop="handleResizeStart(element, $event, 'sw')"></div>
<div class="resize-handle resize-ne" @mousedown.stop="handleResizeStart(element, $event, 'ne')"></div>
<div class="resize-handle resize-nw" @mousedown.stop="handleResizeStart(element, $event, 'nw')"></div>
<div class="resize-handle resize-n" @mousedown.stop="handleResizeStart(element, $event, 'n')"></div>
<div class="resize-handle resize-s" @mousedown.stop="handleResizeStart(element, $event, 's')"></div>
<div class="resize-handle resize-e" @mousedown.stop="handleResizeStart(element, $event, 'e')"></div>
<div class="resize-handle resize-w" @mousedown.stop="handleResizeStart(element, $event, 'w')"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getComponentType as getDesignComponentType, createComponentInstance } from './design-components';
interface ComponentData {
id: string;
type: string;
props: Record<string, any>;
data: Record<string, any>;
style: Record<string, any>;
//
gridX?: number;
gridY?: number;
gridWidth?: number;
gridHeight?: number;
}
const props = defineProps<{
components: ComponentData[];
selectedComponent: ComponentData | null;
}>();
const emit = defineEmits(['component-select', 'component-drop', 'component-move', 'components-change']);
const { createMessage } = useMessage();
const isDragOver = ref(false);
const componentList = computed({
get: () => props.components,
set: (value) => emit('components-change', value),
});
//
const showGrid = ref(true);
const zoomLevel = ref(1);
//
const componentWidth = ref(33.33); // (1/3)
const componentHeight = ref(200); //
const componentsPerRow = ref(3); //
//
const isDragging = ref(false);
const dragData = ref<{
component: ComponentData;
startX: number;
startY: number;
startIndex: number;
longPressTimer: number | null;
} | null>(null);
//
const canvasStyle = computed(() => ({
transform: `scale(${zoomLevel.value})`,
transformOrigin: 'top left',
width: '100%',
minHeight: '100%',
}));
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
isDragOver.value = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
// false
if (!(event.currentTarget as Element).contains(event.relatedTarget as Node)) {
isDragOver.value = false;
}
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver.value = false;
const componentType = event.dataTransfer?.getData('component-type');
if (componentType) {
const newComponent = createComponent(componentType);
emit('component-drop', newComponent);
}
}
function createComponent(type: string): ComponentData {
const component = createComponentInstance(type);
//
const nextPosition = getNextPosition();
component.gridIndex = nextPosition;
return component;
}
//
function getNextPosition(): number {
return componentList.value.length;
}
//
function getComponentStyle(component: ComponentData, index: number) {
const row = Math.floor(index / componentsPerRow.value);
const col = index % componentsPerRow.value;
return {
position: 'absolute',
left: `${col * componentWidth.value}%`,
top: `${row * (componentHeight.value + 20)}px`, // 20px
width: `${componentWidth.value - 1}%`, // 1%
height: `${componentHeight.value}px`,
zIndex: component.isDragging ? 1000 : 1,
};
}
//
function findNextAvailablePosition() {
const occupied = new Set<string>();
//
componentList.value.forEach(comp => {
if (comp.gridX !== undefined && comp.gridY !== undefined &&
comp.gridWidth !== undefined && comp.gridHeight !== undefined) {
for (let x = comp.gridX; x < comp.gridX + comp.gridWidth; x++) {
for (let y = comp.gridY; y < comp.gridY + comp.gridHeight; y++) {
occupied.add(`${x},${y}`);
}
}
}
});
//
for (let y = 0; y < gridRows; y++) {
for (let x = 0; x < gridCols; x++) {
if (!occupied.has(`${x},${y}`)) {
return { x, y };
}
}
}
return { x: 0, y: 0 }; //
}
//
function getDefaultWidth(type: string): number {
switch (type) {
case 'carousel': return 12; //
case 'image': return 6; // 1/4
case 'text': return 8; // 1/3
case 'button': return 4; // 1/6
default: return 6;
}
}
//
function startDragging(component: ComponentData, startX: number, startY: number, startIndex: number) {
isDragging.value = true;
component.isDragging = true;
createMessage.info('长按拖拽模式已激活');
}
//
function updateDragPosition(event: MouseEvent) {
if (!dragData.value) return;
//
const rect = document.querySelector('.components-grid')?.getBoundingClientRect();
if (!rect) return;
const relativeX = event.clientX - rect.left;
const relativeY = event.clientY - rect.top;
const col = Math.floor(relativeX / (rect.width / componentsPerRow.value));
const row = Math.floor(relativeY / (componentHeight.value + 20));
const newIndex = Math.min(
Math.max(0, row * componentsPerRow.value + col),
componentList.value.length - 1
);
//
if (newIndex !== dragData.value.startIndex) {
reorderComponents(dragData.value.startIndex, newIndex);
dragData.value.startIndex = newIndex;
}
}
//
function reorderComponents(fromIndex: number, toIndex: number) {
const newComponents = [...componentList.value];
const [movedComponent] = newComponents.splice(fromIndex, 1);
newComponents.splice(toIndex, 0, movedComponent);
emit('components-change', newComponents);
}
//
function finishDragging() {
if (dragData.value) {
dragData.value.component.isDragging = false;
}
isDragging.value = false;
createMessage.success('组件位置已更新');
}
function handleComponentClick(component: ComponentData) {
if (!isDragging.value) {
emit('component-select', component);
}
}
//
function handleMouseDown(component: ComponentData, event: MouseEvent) {
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startIndex = componentList.value.findIndex(c => c.id === component.id);
//
const longPressTimer = setTimeout(() => {
startDragging(component, startX, startY, startIndex);
}, 500); // 500ms
dragData.value = {
component,
startX,
startY,
startIndex,
longPressTimer,
};
//
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
}
function handleMouseMove(event: MouseEvent) {
if (!dragData.value) return;
const deltaX = Math.abs(event.clientX - dragData.value.startX);
const deltaY = Math.abs(event.clientY - dragData.value.startY);
//
if ((deltaX > 10 || deltaY > 10) && dragData.value.longPressTimer) {
clearTimeout(dragData.value.longPressTimer);
dragData.value.longPressTimer = null;
}
//
if (isDragging.value && dragData.value) {
updateDragPosition(event);
}
}
function handleMouseUp() {
if (dragData.value?.longPressTimer) {
clearTimeout(dragData.value.longPressTimer);
}
if (isDragging.value) {
finishDragging();
}
dragData.value = null;
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
}
//
function handleZoomIn() {
if (zoomLevel.value < 2) {
zoomLevel.value = Math.min(2, zoomLevel.value + 0.1);
}
}
function handleZoomOut() {
if (zoomLevel.value > 0.5) {
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
}
}
function handleZoomReset() {
zoomLevel.value = 1;
}
//
function getGridItemStyle(component: ComponentData) {
const x = component.gridX || 0;
const y = component.gridY || 0;
const width = component.gridWidth || 6;
const height = component.gridHeight || 4;
return {
position: 'absolute',
left: `${x * gridSize}px`,
top: `${y * gridSize}px`,
width: `${width * gridSize}px`,
height: `${height * gridSize}px`,
...component.style,
};
}
//
function handleResizeStart(component: ComponentData, event: MouseEvent, direction?: string) {
if (!direction) return; //
event.preventDefault();
event.stopPropagation();
isResizing.value = true;
resizeData.value = {
component,
startX: event.clientX,
startY: event.clientY,
startWidth: component.gridWidth || 6,
startHeight: component.gridHeight || 4,
direction,
};
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
}
function handleResizeMove(event: MouseEvent) {
if (!isResizing.value || !resizeData.value) return;
const { component, startX, startY, startWidth, startHeight, direction } = resizeData.value;
const deltaX = Math.round((event.clientX - startX) / (gridSize * zoomLevel.value));
const deltaY = Math.round((event.clientY - startY) / (gridSize * zoomLevel.value));
let newWidth = startWidth;
let newHeight = startHeight;
//
if (direction.includes('e')) newWidth = Math.max(1, startWidth + deltaX);
if (direction.includes('w')) newWidth = Math.max(1, startWidth - deltaX);
if (direction.includes('s')) newHeight = Math.max(1, startHeight + deltaY);
if (direction.includes('n')) newHeight = Math.max(1, startHeight - deltaY);
//
component.gridWidth = newWidth;
component.gridHeight = newHeight;
emit('component-select', component); //
}
function handleResizeEnd() {
isResizing.value = false;
resizeData.value = null;
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
}
function handleMoveUp(index: number) {
if (index > 0) {
const newComponents = [...componentList.value];
[newComponents[index - 1], newComponents[index]] = [newComponents[index], newComponents[index - 1]];
emit('components-change', newComponents);
}
}
function handleMoveDown(index: number) {
if (index < componentList.value.length - 1) {
const newComponents = [...componentList.value];
[newComponents[index], newComponents[index + 1]] = [newComponents[index + 1], newComponents[index]];
emit('components-change', newComponents);
}
}
function handleCopy(component: ComponentData) {
const newComponent = {
...component,
id: `${component.type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
};
const newComponents = [...componentList.value, newComponent];
emit('components-change', newComponents);
createMessage.success('组件复制成功');
}
function handleDelete(index: number) {
const newComponents = componentList.value.filter((_, i) => i !== index);
emit('components-change', newComponents);
emit('component-select', null);
createMessage.success('组件删除成功');
}
function handleClearAll() {
emit('components-change', []);
emit('component-select', null);
createMessage.success('画布已清空');
}
function handleSortStart() {
//
}
function handleSortEnd() {
//
}
function getComponentType(type: string) {
return getDesignComponentType(type);
}
//
onMounted(() => {
//
componentList.value.forEach(component => {
if (component.gridX === undefined || component.gridY === undefined) {
const nextPosition = findNextAvailablePosition();
component.gridX = nextPosition.x;
component.gridY = nextPosition.y;
component.gridWidth = component.gridWidth || getDefaultWidth(component.type);
component.gridHeight = component.gridHeight || getDefaultHeight(component.type);
}
});
});
onUnmounted(() => {
//
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
});
</script>
<style scoped>
.design-canvas {
width: 100% !important;
height: 100%;
display: flex;
flex-direction: column;
background: white;
position: relative;
max-width: none !important;
}
.canvas-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.canvas-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.canvas-controls {
display: flex;
align-items: center;
gap: 20px;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-display {
min-width: 50px;
text-align: center;
font-size: 12px;
color: #666;
}
.grid-controls {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
.canvas-container {
flex: 1;
overflow: auto;
background: #f0f2f5;
position: relative;
width: 100% !important;
height: 100%;
max-width: none !important;
}
.canvas-content {
width: 100% !important;
min-height: calc(100vh - 48px);
background: white;
position: relative;
padding: 20px;
transition: all 0.2s;
box-sizing: border-box;
flex: 1;
max-width: none !important;
}
.canvas-content.drag-over {
background-color: #f0f9ff;
border: 2px dashed #1890ff;
}
.canvas-content.show-grid {
background-image:
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
background-size: 40px 40px;
}
.grid-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background-image:
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
background-size: 40px 40px;
}
.empty-canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
z-index: 1;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
text-align: center;
}
.empty-text p {
margin: 0;
font-size: 16px;
}
.empty-subtext {
font-size: 14px !important;
margin-top: 8px !important;
}
/* 网格布局容器 */
.components-grid {
position: relative;
width: 100%;
min-height: 100%;
padding: 20px;
}
/* 组件项样式 */
.component-item {
position: absolute;
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
background: #fafafa;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
user-select: none;
}
.component-item:hover {
border-color: #d9d9d9;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.component-item.selected {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
background: white;
}
.component-item.dragging {
opacity: 0.8;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.component-toolbar {
position: absolute;
top: 8px;
right: 8px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
opacity: 0.9;
}
.component-content {
padding: 12px;
height: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
/* 缩放手柄样式 */
.resize-handles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.resize-handle {
position: absolute;
background: #1890ff;
border: 1px solid white;
pointer-events: auto;
z-index: 10;
}
.resize-handle:hover {
background: #40a9ff;
}
/* 角落手柄 */
.resize-se {
bottom: -4px;
right: -4px;
width: 8px;
height: 8px;
cursor: se-resize;
}
.resize-sw {
bottom: -4px;
left: -4px;
width: 8px;
height: 8px;
cursor: sw-resize;
}
.resize-ne {
top: -4px;
right: -4px;
width: 8px;
height: 8px;
cursor: ne-resize;
}
.resize-nw {
top: -4px;
left: -4px;
width: 8px;
height: 8px;
cursor: nw-resize;
}
/* 边缘手柄 */
.resize-n {
top: -4px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
cursor: n-resize;
}
.resize-s {
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
cursor: s-resize;
}
.resize-e {
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
cursor: e-resize;
}
.resize-w {
left: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
cursor: w-resize;
}
</style>

View File

@ -0,0 +1,854 @@
<template>
<div class="property-editor">
<div class="editor-header">
<h3>属性编辑</h3>
</div>
<div class="editor-content">
<!-- 未选中组件时的提示 -->
<div v-if="!component" class="empty-state">
<div class="empty-icon">
<Icon icon="ion:settings-outline" />
</div>
<p>请选择一个组件来编辑属性</p>
</div>
<!-- 组件属性编辑表单 -->
<div v-else class="property-form">
<!-- 组件基本信息 -->
<div class="property-section">
<div class="section-title">
<Icon icon="ion:information-circle-outline" />
<span>基本信息</span>
</div>
<div class="form-item">
<label>组件类型</label>
<a-input :value="getComponentTypeName(component.type)" disabled />
</div>
<div class="form-item">
<label>组件ID</label>
<a-input :value="component.id" disabled />
</div>
</div>
<!-- 轮播图属性 -->
<template v-if="component.type === 'carousel'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:images-outline" />
<span>轮播设置</span>
</div>
<div class="form-item">
<label>自动播放</label>
<a-switch
:checked="component.props.autoplay"
@change="updateProperty('props.autoplay', $event)"
/>
</div>
<div class="form-item">
<label>显示指示点</label>
<a-switch
:checked="component.props.dots"
@change="updateProperty('props.dots', $event)"
/>
</div>
<div class="form-item">
<label>显示箭头</label>
<a-switch
:checked="component.props.arrows"
@change="updateProperty('props.arrows', $event)"
/>
</div>
</div>
<div class="property-section">
<div class="section-title">
<Icon icon="ion:image-outline" />
<span>图片管理</span>
</div>
<div class="image-list">
<div
v-for="(image, index) in component.data.images"
:key="index"
class="carousel-image-item"
>
<!-- 图片预览区域 -->
<div class="carousel-image-preview">
<div v-if="image.url" class="preview-container">
<img :src="image.url" :alt="image.alt" />
<div class="preview-overlay">
<a-button size="small" @click="removeCarouselImage(index)" danger>
<template #icon>
<Icon icon="ion:trash-outline" />
</template>
</a-button>
</div>
</div>
<!-- 上传区域 -->
<div v-else class="carousel-upload-area" @click="triggerCarouselFileInput(index)">
<Icon icon="ion:cloud-upload-outline" />
<p>点击上传图片</p>
</div>
<!-- 隐藏的文件输入 -->
<input
:ref="el => setCarouselFileInputRef(index, el)"
type="file"
accept="image/*"
style="display: none"
@change="handleCarouselFileUpload(index, $event)"
/>
</div>
<!-- 图片信息编辑 -->
<div class="carousel-image-info">
<div class="form-row">
<a-input
v-model:value="image.url"
placeholder="或输入图片URL"
size="small"
@change="updateImageData"
/>
<a-button size="small" @click="triggerCarouselFileInput(index)">
<template #icon>
<Icon icon="ion:folder-open-outline" />
</template>
</a-button>
</div>
<a-input
v-model:value="image.title"
placeholder="图片标题(可选)"
size="small"
@change="updateImageData"
/>
<a-input
v-model:value="image.description"
placeholder="图片描述(可选)"
size="small"
@change="updateImageData"
/>
<a-input
v-model:value="image.alt"
placeholder="替代文本(可选)"
size="small"
@change="updateImageData"
/>
</div>
</div>
<a-button @click="addImage" type="dashed" block>
<template #icon>
<Icon icon="ion:add" />
</template>
添加图片
</a-button>
</div>
</div>
</template>
<!-- 文本属性 -->
<template v-if="component.type === 'text'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:text-outline" />
<span>文本内容</span>
</div>
<div class="form-item">
<label>标签类型</label>
<a-select
:value="component.props.tag"
@change="updateProperty('props.tag', $event)"
style="width: 100%"
>
<a-select-option value="p">段落 (p)</a-select-option>
<a-select-option value="h1">标题1 (h1)</a-select-option>
<a-select-option value="h2">标题2 (h2)</a-select-option>
<a-select-option value="h3">标题3 (h3)</a-select-option>
<a-select-option value="h4">标题4 (h4)</a-select-option>
<a-select-option value="span">行内文本 (span)</a-select-option>
</a-select>
</div>
<div class="form-item">
<label>文本内容</label>
<a-textarea
:value="component.data.content"
@change="updateProperty('data.content', $event.target.value)"
:rows="4"
placeholder="请输入文本内容"
/>
</div>
</div>
</template>
<!-- 图片属性 -->
<template v-if="component.type === 'image'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:image-outline" />
<span>图片设置</span>
</div>
<div class="form-item">
<label>图片</label>
<div class="image-upload-container">
<!-- 图片预览 -->
<div v-if="component.data.src" class="image-preview-box">
<img :src="component.data.src" alt="预览图片" />
<div class="image-overlay">
<a-button size="small" @click="removeCurrentImage" danger>
<template #icon>
<Icon icon="ion:trash-outline" />
</template>
</a-button>
</div>
</div>
<!-- 上传区域 -->
<div v-else class="upload-area" @click="triggerFileInput">
<Icon icon="ion:cloud-upload-outline" />
<p>点击上传图片</p>
<p class="upload-tip">支持 JPGPNGGIF 格式</p>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileUpload"
/>
</div>
<!-- URL输入框 -->
<div class="url-input-container">
<a-input
:value="component.data.src"
@change="updateProperty('data.src', $event.target.value)"
placeholder="或输入图片URL"
size="small"
/>
<a-button size="small" @click="triggerFileInput">
<template #icon>
<Icon icon="ion:folder-open-outline" />
</template>
选择文件
</a-button>
</div>
</div>
<div class="form-item">
<label>替代文本</label>
<a-input
:value="component.props.alt"
@change="updateProperty('props.alt', $event.target.value)"
placeholder="图片描述"
/>
</div>
<div class="form-item">
<label>显示方式</label>
<a-select
:value="component.props.objectFit"
@change="updateProperty('props.objectFit', $event)"
style="width: 100%"
>
<a-select-option value="cover">覆盖</a-select-option>
<a-select-option value="contain">包含</a-select-option>
<a-select-option value="fill">填充</a-select-option>
<a-select-option value="none">原始</a-select-option>
<a-select-option value="scale-down">缩小</a-select-option>
</a-select>
</div>
</div>
</template>
<!-- 按钮属性 -->
<template v-if="component.type === 'button'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:radio-button-on-outline" />
<span>按钮设置</span>
</div>
<div class="form-item">
<label>按钮文字</label>
<a-input
:value="component.data.text"
@change="updateProperty('data.text', $event.target.value)"
placeholder="按钮文字"
/>
</div>
<div class="form-item">
<label>按钮类型</label>
<a-select
:value="component.props.type"
@change="updateProperty('props.type', $event)"
style="width: 100%"
>
<a-select-option value="primary">主要按钮</a-select-option>
<a-select-option value="default">默认按钮</a-select-option>
<a-select-option value="dashed">虚线按钮</a-select-option>
<a-select-option value="text">文本按钮</a-select-option>
<a-select-option value="link">链接按钮</a-select-option>
</a-select>
</div>
<div class="form-item">
<label>按钮大小</label>
<a-select
:value="component.props.size"
@change="updateProperty('props.size', $event)"
style="width: 100%"
>
<a-select-option value="large"></a-select-option>
<a-select-option value="middle"></a-select-option>
<a-select-option value="small"></a-select-option>
</a-select>
</div>
</div>
</template>
<!-- 样式设置 -->
<div class="property-section">
<div class="section-title">
<Icon icon="ion:color-palette-outline" />
<span>样式设置</span>
</div>
<div class="form-item">
<label>宽度</label>
<a-input
:value="component.style.width"
@change="updateProperty('style.width', $event.target.value)"
placeholder="如: 100%, 300px"
/>
</div>
<div class="form-item">
<label>高度</label>
<a-input
:value="component.style.height"
@change="updateProperty('style.height', $event.target.value)"
placeholder="如: auto, 200px"
/>
</div>
<div class="form-item">
<label>外边距</label>
<a-input
:value="component.style.margin"
@change="updateProperty('style.margin', $event.target.value)"
placeholder="如: 10px, 10px 20px"
/>
</div>
<div class="form-item">
<label>内边距</label>
<a-input
:value="component.style.padding"
@change="updateProperty('style.padding', $event.target.value)"
placeholder="如: 10px, 10px 20px"
/>
</div>
<!-- 文本样式 -->
<template v-if="component.type === 'text'">
<div class="form-item">
<label>字体大小</label>
<a-input
:value="component.style.fontSize"
@change="updateProperty('style.fontSize', $event.target.value)"
placeholder="如: 16px, 1.2em"
/>
</div>
<div class="form-item">
<label>字体颜色</label>
<a-input
:value="component.style.color"
@change="updateProperty('style.color', $event.target.value)"
placeholder="如: #333, red"
/>
</div>
<div class="form-item">
<label>文本对齐</label>
<a-select
:value="component.style.textAlign"
@change="updateProperty('style.textAlign', $event)"
style="width: 100%"
>
<a-select-option value="left">左对齐</a-select-option>
<a-select-option value="center">居中</a-select-option>
<a-select-option value="right">右对齐</a-select-option>
<a-select-option value="justify">两端对齐</a-select-option>
</a-select>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
interface ComponentData {
id: string;
type: string;
props: Record<string, any>;
data: Record<string, any>;
style: Record<string, any>;
}
const props = defineProps<{
component: ComponentData | null;
}>();
const emit = defineEmits(['property-change']);
const { createMessage } = useMessage();
const fileInput = ref<HTMLInputElement>();
const carouselFileInputs = ref<Record<number, HTMLInputElement>>({});
function getComponentTypeName(type: string) {
const typeNames = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return typeNames[type] || type;
}
function updateProperty(path: string, value: any) {
if (!props.component) return;
const keys = path.split('.');
const component = { ...props.component };
let target = component;
for (let i = 0; i < keys.length - 1; i++) {
if (!target[keys[i]]) {
target[keys[i]] = {};
}
target = target[keys[i]];
}
target[keys[keys.length - 1]] = value;
emit('property-change', component);
}
function addImage() {
if (!props.component || props.component.type !== 'carousel') return;
const newImage = {
url: 'https://via.placeholder.com/800x300/666/white?text=New+Image',
alt: 'New Image',
};
const component = { ...props.component };
component.data.images = [...component.data.images, newImage];
emit('property-change', component);
}
function removeImage(index: number) {
if (!props.component || props.component.type !== 'carousel') return;
const component = { ...props.component };
component.data.images = component.data.images.filter((_, i) => i !== index);
emit('property-change', component);
}
function updateImageData() {
if (!props.component) return;
emit('property-change', { ...props.component });
}
//
function triggerFileInput() {
fileInput.value?.click();
}
function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
//
if (!file.type.startsWith('image/')) {
createMessage.error('请选择图片文件!');
return;
}
// 5MB
if (file.size > 5 * 1024 * 1024) {
createMessage.error('图片大小不能超过5MB');
return;
}
// 使FileReaderbase64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
updateProperty('data.src', base64);
createMessage.success('图片上传成功!');
};
reader.onerror = () => {
createMessage.error('图片读取失败!');
};
reader.readAsDataURL(file);
// input
target.value = '';
}
function removeCurrentImage() {
updateProperty('data.src', '');
createMessage.success('图片已移除!');
}
//
function setCarouselFileInputRef(index: number, el: HTMLInputElement | null) {
if (el) {
carouselFileInputs.value[index] = el;
}
}
function triggerCarouselFileInput(index: number) {
carouselFileInputs.value[index]?.click();
}
function handleCarouselFileUpload(index: number, event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
//
if (!file.type.startsWith('image/')) {
createMessage.error('请选择图片文件!');
return;
}
// 5MB
if (file.size > 5 * 1024 * 1024) {
createMessage.error('图片大小不能超过5MB');
return;
}
// 使FileReaderbase64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
// URL
if (props.component && props.component.data.images[index]) {
props.component.data.images[index].url = base64;
updateImageData();
createMessage.success('图片上传成功!');
}
};
reader.onerror = () => {
createMessage.error('图片读取失败!');
};
reader.readAsDataURL(file);
// input
target.value = '';
}
function removeCarouselImage(index: number) {
if (props.component && props.component.data.images) {
props.component.data.images.splice(index, 1);
updateImageData();
createMessage.success('图片已删除!');
}
}
</script>
<style scoped>
.property-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.editor-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.property-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: #666;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.section-title .anticon {
margin-right: 8px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.image-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.image-item {
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 12px;
}
.image-preview {
margin-bottom: 8px;
}
.image-preview img {
width: 100%;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.image-actions {
display: flex;
gap: 8px;
align-items: center;
}
.image-actions .ant-input {
flex: 1;
}
/* 图片上传样式 */
.image-upload-container {
margin-bottom: 12px;
}
.image-preview-box {
position: relative;
width: 100%;
height: 120px;
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.image-preview-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.image-preview-box:hover .image-overlay {
opacity: 1;
}
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 6px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
}
.upload-area:hover {
border-color: #1890ff;
background: #f0f8ff;
}
.upload-area .anticon {
font-size: 32px;
color: #999;
margin-bottom: 8px;
}
.upload-area p {
margin: 0;
color: #666;
font-size: 14px;
}
.upload-tip {
font-size: 12px !important;
color: #999 !important;
margin-top: 4px !important;
}
.url-input-container {
display: flex;
gap: 8px;
align-items: center;
}
.url-input-container .ant-input {
flex: 1;
}
/* 轮播图图片管理样式 */
.carousel-image-item {
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
background: #fafafa;
}
.carousel-image-preview {
margin-bottom: 12px;
}
.preview-container {
position: relative;
width: 100%;
height: 80px;
border-radius: 4px;
overflow: hidden;
}
.preview-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.preview-container:hover .preview-overlay {
opacity: 1;
}
.carousel-upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80px;
border: 2px dashed #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.carousel-upload-area:hover {
border-color: #1890ff;
background: #f0f8ff;
}
.carousel-upload-area .anticon {
font-size: 20px;
color: #999;
margin-bottom: 4px;
}
.carousel-upload-area p {
margin: 0;
color: #666;
font-size: 12px;
}
.carousel-image-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row {
display: flex;
gap: 8px;
align-items: center;
}
.form-row .ant-input {
flex: 1;
}
</style>

View File

@ -0,0 +1,232 @@
<template>
<div class="button-component" :class="{ 'design-mode': designMode }">
<a-button
v-bind="buttonProps"
:type="type"
:size="size"
:shape="shape"
:loading="loading"
:disabled="disabled"
:ghost="ghost"
:danger="danger"
:block="block"
class="button-content"
:style="buttonStyle"
@click="handleClick"
>
<template #icon v-if="iconName">
<Icon :icon="iconName" />
</template>
{{ text }}
</a-button>
<!-- 设计模式下的空状态 -->
<div v-if="designMode && !text" class="empty-button">
<div class="empty-content">
<Icon icon="ion:radio-button-on-outline" />
<p>按钮组件</p>
<p class="empty-tip">请在右侧属性面板设置按钮文字</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface ButtonData {
text: string;
url?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
}
const props = withDefaults(defineProps<{
//
type?: 'primary' | 'default' | 'dashed' | 'text' | 'link';
size?: 'large' | 'middle' | 'small';
shape?: 'default' | 'circle' | 'round';
loading?: boolean;
disabled?: boolean;
ghost?: boolean;
danger?: boolean;
block?: boolean;
//
iconName?: string;
//
data?: ButtonData;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
type: 'primary',
size: 'middle',
shape: 'default',
loading: false,
disabled: false,
ghost: false,
danger: false,
block: false,
designMode: false,
data: () => ({ text: '' }),
});
const emit = defineEmits(['click']);
const text = computed(() => props.data?.text || '');
const buttonProps = computed(() => {
const {
type, size, shape, loading, disabled, ghost, danger, block,
iconName, data, designMode, onClick, ...rest
} = props;
return rest;
});
const buttonStyle = computed(() => {
const style: Record<string, any> = {};
// props
const styleProps = [
'width', 'height', 'margin', 'padding',
'backgroundColor', 'borderColor', 'color',
'fontSize', 'fontWeight', 'borderRadius',
'boxShadow', 'textTransform'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleClick(event: Event) {
if (props.designMode) {
event.preventDefault();
return;
}
//
if (props.data?.url) {
const target = props.data.target || '_self';
if (target === '_blank') {
window.open(props.data.url, '_blank');
} else {
window.location.href = props.data.url;
}
}
if (props.onClick) {
props.onClick();
}
emit('click', event);
}
</script>
<style scoped>
.button-component {
display: inline-block;
position: relative;
}
.button-component.design-mode {
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
padding: 2px;
}
.button-component.design-mode:hover {
border-color: #d9d9d9;
}
.button-content {
transition: all 0.3s ease;
}
.button-content:not(.ant-btn-loading):hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.empty-button {
min-width: 120px;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 12px;
}
.empty-tip {
margin-top: 4px !important;
font-size: 11px !important;
}
/* 按钮尺寸自定义 */
:deep(.ant-btn-lg) {
height: 48px;
padding: 0 24px;
font-size: 16px;
}
:deep(.ant-btn-sm) {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* 块级按钮 */
.button-component :deep(.ant-btn-block) {
width: 100%;
}
/* 圆形按钮 */
.button-component :deep(.ant-btn-circle) {
min-width: auto;
}
/* 响应式设计 */
@media (max-width: 768px) {
.button-content:not(.ant-btn-loading):hover {
transform: none;
box-shadow: none;
}
:deep(.ant-btn-lg) {
height: 44px;
font-size: 15px;
}
}
</style>

View File

@ -0,0 +1,230 @@
<template>
<div class="carousel-component" :class="{ 'design-mode': designMode }">
<a-carousel
v-bind="carouselProps"
:autoplay="autoplay"
:dots="dots"
:arrows="arrows"
:effect="effect"
:dotPosition="dotPosition"
>
<div v-for="(image, index) in images" :key="index" class="carousel-slide">
<div class="slide-content" :style="slideStyle">
<img
:src="image.url"
:alt="image.alt || `Slide ${index + 1}`"
class="slide-image"
@error="handleImageError"
/>
<div v-if="image.title || image.description" class="slide-overlay">
<h3 v-if="image.title" class="slide-title">{{ image.title }}</h3>
<p v-if="image.description" class="slide-description">{{ image.description }}</p>
</div>
</div>
</div>
</a-carousel>
<!-- 设计模式下的编辑提示 -->
<div v-if="designMode && images.length === 0" class="empty-carousel">
<div class="empty-content">
<Icon icon="ion:images-outline" />
<p>轮播图组件</p>
<p class="empty-tip">请在右侧属性面板添加图片</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface ImageData {
url: string;
alt?: string;
title?: string;
description?: string;
}
interface CarouselData {
images: ImageData[];
}
const props = withDefaults(defineProps<{
//
autoplay?: boolean;
dots?: boolean;
arrows?: boolean;
effect?: 'scrollx' | 'fade';
dotPosition?: 'top' | 'bottom' | 'left' | 'right';
//
data?: CarouselData;
//
designMode?: boolean;
//
[key: string]: any;
}>(), {
autoplay: true,
dots: true,
arrows: false,
effect: 'scrollx',
dotPosition: 'bottom',
designMode: false,
data: () => ({ images: [] }),
});
const images = computed(() => props.data?.images || []);
const carouselProps = computed(() => {
const { autoplay, dots, arrows, effect, dotPosition, data, designMode, ...rest } = props;
return rest;
});
const slideStyle = computed(() => ({
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}));
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = 'https://via.placeholder.com/800x300/f0f0f0/999?text=Image+Load+Error';
}
</script>
<style scoped>
.carousel-component {
width: 100%;
height: 100%;
min-height: 200px;
position: relative;
}
.carousel-component.design-mode {
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.carousel-slide {
height: 300px;
overflow: hidden;
}
.slide-content {
width: 100%;
height: 100%;
position: relative;
}
.slide-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.slide-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 20px;
}
.slide-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: bold;
}
.slide-description {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.empty-carousel {
width: 100%;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 14px;
}
.empty-tip {
font-size: 12px !important;
margin-top: 8px !important;
}
/* 自定义轮播图样式 */
:deep(.ant-carousel) {
height: 100%;
}
:deep(.ant-carousel .slick-slide) {
height: 300px;
}
:deep(.ant-carousel .slick-slide > div) {
height: 100%;
}
:deep(.ant-carousel .slick-dots) {
bottom: 20px;
}
:deep(.ant-carousel .slick-dots li button) {
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
}
:deep(.ant-carousel .slick-dots li.slick-active button) {
background: white;
}
:deep(.ant-carousel .slick-arrow) {
z-index: 2;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
:deep(.ant-carousel .slick-arrow:hover) {
background: rgba(0, 0, 0, 0.7);
}
:deep(.ant-carousel .slick-prev) {
left: 20px;
}
:deep(.ant-carousel .slick-next) {
right: 20px;
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<div class="image-component" :class="{ 'design-mode': designMode }">
<div class="image-wrapper" :style="wrapperStyle">
<img
v-if="src"
:src="src"
:alt="alt"
class="image-content"
:style="imageStyle"
@load="handleImageLoad"
@error="handleImageError"
@click="handleClick"
/>
<!-- 设计模式下的空状态 -->
<div v-if="designMode && !src" class="empty-image">
<div class="empty-content">
<Icon icon="ion:image-outline" />
<p>图片组件</p>
<p class="empty-tip">请在右侧属性面板设置图片URL</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { Icon } from '/@/components/Icon';
interface ImageData {
src: string;
}
const props = withDefaults(defineProps<{
//
alt?: string;
loading?: 'lazy' | 'eager';
//
data?: ImageData;
//
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
borderRadius?: string;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
alt: '',
loading: 'lazy',
objectFit: 'cover',
designMode: false,
data: () => ({ src: '' }),
});
const emit = defineEmits(['click', 'load', 'error']);
const imageLoaded = ref(false);
const imageError = ref(false);
const src = computed(() => props.data?.src || '');
const wrapperStyle = computed(() => {
const style: Record<string, any> = {
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
};
if (props.borderRadius) {
style.borderRadius = props.borderRadius;
}
return style;
});
const imageStyle = computed(() => {
const style: Record<string, any> = {
width: '100%',
height: '100%',
display: 'block',
objectFit: props.objectFit,
};
// props
const styleProps = [
'border', 'boxShadow', 'filter', 'opacity', 'transform'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleImageLoad(event: Event) {
imageLoaded.value = true;
imageError.value = false;
emit('load', event);
}
function handleImageError(event: Event) {
imageError.value = true;
imageLoaded.value = false;
//
const img = event.target as HTMLImageElement;
img.src = 'https://via.placeholder.com/400x200/f0f0f0/999?text=Image+Load+Error';
emit('error', event);
}
function handleClick(event: Event) {
if (props.onClick) {
props.onClick();
}
emit('click', event);
}
</script>
<style scoped>
.image-component {
width: 100%;
height: 100%;
min-height: 100px;
position: relative;
}
.image-component.design-mode {
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
}
.image-component.design-mode:hover {
border-color: #d9d9d9;
}
.image-wrapper {
width: 100%;
height: 100%;
position: relative;
background: #fafafa;
}
.image-content {
cursor: pointer;
transition: all 0.3s ease;
}
.image-content:hover {
transform: scale(1.02);
}
.empty-image {
width: 100%;
height: 100%;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 14px;
}
.empty-tip {
font-size: 12px !important;
margin-top: 8px !important;
}
/* 图片加载状态 */
.image-content[src=""] {
display: none;
}
/* 响应式图片 */
@media (max-width: 768px) {
.image-content:hover {
transform: none;
}
}
</style>

View File

@ -0,0 +1,223 @@
<template>
<div class="text-component" :class="{ 'design-mode': designMode }">
<component
:is="tag"
class="text-content"
:style="textStyle"
v-html="processedContent"
@click="handleClick"
/>
<!-- 设计模式下的编辑提示 -->
<div v-if="designMode && !content" class="empty-text">
<div class="empty-content">
<Icon icon="ion:text-outline" />
<p>文本组件</p>
<p class="empty-tip">请在右侧属性面板编辑文本内容</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface TextData {
content: string;
}
const props = withDefaults(defineProps<{
// HTML
tag?: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div';
//
data?: TextData;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
tag: 'p',
designMode: false,
data: () => ({ content: '' }),
});
const emit = defineEmits(['click']);
const content = computed(() => props.data?.content || '');
const processedContent = computed(() => {
if (!content.value) return '';
//
return content.value.replace(/\n/g, '<br>');
});
const textStyle = computed(() => {
const style: Record<string, any> = {};
// props
const styleProps = [
'fontSize', 'fontWeight', 'fontFamily', 'fontStyle',
'color', 'backgroundColor', 'textAlign', 'textDecoration',
'lineHeight', 'letterSpacing', 'wordSpacing',
'textShadow', 'textTransform',
'margin', 'padding', 'width', 'height',
'border', 'borderRadius', 'boxShadow'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
// CSS
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleClick() {
if (props.onClick) {
props.onClick();
}
emit('click');
}
</script>
<style scoped>
.text-component {
width: 100%;
position: relative;
}
.text-component.design-mode {
min-height: 40px;
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
}
.text-component.design-mode:hover {
border-color: #d9d9d9;
}
.text-content {
width: 100%;
word-wrap: break-word;
word-break: break-word;
cursor: inherit;
}
.text-content:deep(br) {
line-height: 1.5;
}
.empty-text {
width: 100%;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 12px;
}
.empty-tip {
margin-top: 4px !important;
font-size: 11px !important;
}
/* 预设文本样式 */
.text-content.heading-1 {
font-size: 32px;
font-weight: bold;
line-height: 1.2;
margin-bottom: 16px;
}
.text-content.heading-2 {
font-size: 28px;
font-weight: bold;
line-height: 1.3;
margin-bottom: 14px;
}
.text-content.heading-3 {
font-size: 24px;
font-weight: bold;
line-height: 1.4;
margin-bottom: 12px;
}
.text-content.heading-4 {
font-size: 20px;
font-weight: bold;
line-height: 1.4;
margin-bottom: 10px;
}
.text-content.paragraph {
font-size: 16px;
line-height: 1.6;
margin-bottom: 16px;
}
.text-content.small-text {
font-size: 14px;
line-height: 1.5;
color: #666;
}
.text-content.large-text {
font-size: 18px;
line-height: 1.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.text-content.heading-1 {
font-size: 28px;
}
.text-content.heading-2 {
font-size: 24px;
}
.text-content.heading-3 {
font-size: 20px;
}
.text-content.heading-4 {
font-size: 18px;
}
.text-content.paragraph,
.text-content.large-text {
font-size: 16px;
}
}
</style>

View File

@ -0,0 +1,143 @@
import { App } from 'vue';
import CarouselComponent from './CarouselComponent.vue';
import TextComponent from './TextComponent.vue';
import ImageComponent from './ImageComponent.vue';
import ButtonComponent from './ButtonComponent.vue';
//
export const componentMap = {
carousel: CarouselComponent,
text: TextComponent,
image: ImageComponent,
button: ButtonComponent,
};
//
export function getComponentType(type: string) {
return componentMap[type] || 'div';
}
//
export function registerDesignComponents(app: App) {
Object.entries(componentMap).forEach(([name, component]) => {
app.component(`Design${name.charAt(0).toUpperCase() + name.slice(1)}Component`, component);
});
}
//
export {
CarouselComponent,
TextComponent,
ImageComponent,
ButtonComponent,
};
//
export const componentConfigs = {
carousel: {
name: '轮播图',
icon: 'ion:images-outline',
description: '图片轮播展示',
category: 'basic',
defaultProps: {
autoplay: true,
dots: true,
arrows: false,
effect: 'scrollx',
dotPosition: 'bottom',
},
defaultData: {
images: [
{
url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1',
alt: 'Slide 1'
},
{
url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2',
alt: 'Slide 2'
},
],
},
defaultStyle: {
width: '100%',
height: '300px',
marginBottom: '20px',
},
},
text: {
name: '文本',
icon: 'ion:text-outline',
description: '文字内容展示',
category: 'basic',
defaultProps: {
tag: 'p',
},
defaultData: {
content: '这是一段文本内容,点击右侧属性面板可以编辑。',
},
defaultStyle: {
fontSize: '16px',
color: '#333',
lineHeight: '1.6',
marginBottom: '16px',
},
},
image: {
name: '图片',
icon: 'ion:image-outline',
description: '单张图片展示',
category: 'basic',
defaultProps: {
alt: '图片',
objectFit: 'cover',
},
defaultData: {
src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image',
},
defaultStyle: {
width: '400px',
height: '200px',
marginBottom: '16px',
},
},
button: {
name: '按钮',
icon: 'ion:radio-button-on-outline',
description: '交互按钮',
category: 'basic',
defaultProps: {
type: 'primary',
size: 'middle',
shape: 'default',
},
defaultData: {
text: '按钮文字',
},
defaultStyle: {
marginBottom: '16px',
},
},
};
//
export function getComponentConfig(type: string) {
return componentConfigs[type];
}
//
export function createComponentInstance(type: string) {
const config = getComponentConfig(type);
if (!config) {
throw new Error(`Unknown component type: ${type}`);
}
const id = `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return {
id,
type,
props: { ...config.defaultProps },
data: { ...config.defaultData },
style: { ...config.defaultStyle },
};
}

View File

@ -0,0 +1,519 @@
<template>
<div class="portal-design-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">门户设计</h1>
<p class="page-subtitle">创建和管理您的门户页面设计</p>
</div>
<div class="header-right">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索门户设计..."
style="width: 300px; margin-right: 16px"
@search="handleSearch"
/>
<a-button type="primary" size="large" @click="handleCreate">
<template #icon>
<Icon icon="ion:add" />
</template>
新建门户
</a-button>
</div>
</div>
</div>
<!-- 门户卡片网格 -->
<div class="portal-grid-container">
<a-spin :spinning="loading">
<!-- 新建门户卡片 -->
<div class="portal-grid">
<div class="portal-card create-card" @click="handleCreate">
<div class="create-card-content">
<div class="create-icon">
<Icon icon="ion:add-outline" />
</div>
<div class="create-text">新建门户</div>
</div>
</div>
<!-- 现有门户卡片 -->
<div
v-for="portal in filteredPortals"
:key="portal.id"
class="portal-card"
@click="handleEdit(portal)"
>
<!-- 缩略图 -->
<div class="portal-thumbnail">
<div class="thumbnail-placeholder">
<Icon icon="ion:desktop-outline" />
<span>{{ portal.name }}</span>
</div>
<!-- 悬停遮罩 -->
<div class="portal-overlay">
<div class="overlay-actions">
<a-button type="primary" @click.stop="handleEdit(portal)">
<template #icon>
<Icon icon="ion:create-outline" />
</template>
设计
</a-button>
<a-button @click.stop="handlePreview(portal)">
<template #icon>
<Icon icon="ion:eye-outline" />
</template>
预览
</a-button>
</div>
</div>
</div>
<!-- 卡片信息 -->
<div class="portal-info">
<div class="portal-title">{{ portal.name }}</div>
<div class="portal-meta">
<span class="portal-status" :class="{ 'published': portal.status === 1 }">
{{ portal.status === 1 ? '已发布' : '草稿' }}
</span>
<span class="portal-time">{{ formatTime(portal.updateTime) }}</span>
</div>
</div>
<!-- 操作菜单 -->
<div class="portal-actions">
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" @click.stop>
<Icon icon="ion:ellipsis-horizontal" />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEdit(portal)">
<Icon icon="ion:create-outline" />
编辑设计
</a-menu-item>
<a-menu-item @click="handlePreview(portal)">
<Icon icon="ion:eye-outline" />
预览
</a-menu-item>
<a-menu-item @click="handleCopy(portal)">
<Icon icon="ion:copy-outline" />
复制
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleDelete(portal)" class="danger-item">
<Icon icon="ion:trash-outline" />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredPortals.length === 0 && !loading" class="empty-state">
<div class="empty-icon">
<Icon icon="ion:desktop-outline" />
</div>
<div class="empty-text">
<h3>还没有门户设计</h3>
<p>点击"新建门户"开始创建您的第一个门户页面</p>
</div>
<a-button type="primary" @click="handleCreate">
<template #icon>
<Icon icon="ion:add" />
</template>
新建门户
</a-button>
</div>
</a-spin>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const dataSource = ref<any[]>([]);
const searchKeyword = ref('');
//
const filteredPortals = computed(() => {
if (!searchKeyword.value) {
return dataSource.value;
}
return dataSource.value.filter(portal =>
portal.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
portal.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
);
});
//
function handleSearch(value: string) {
searchKeyword.value = value;
}
//
function formatTime(time: string) {
if (!time) return '';
const date = new Date(time);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return '今天';
} else if (days === 1) {
return '昨天';
} else if (days < 7) {
return `${days}天前`;
} else {
return date.toLocaleDateString();
}
}
//
function handleCopy(portal: any) {
const newPortal = {
...portal,
id: Date.now(),
name: `${portal.name} - 副本`,
status: 0, // 稿
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
};
dataSource.value.push(newPortal);
saveToLocalStorage();
createMessage.success('门户复制成功');
}
function handleCreate() {
router.push('/online-design/editor');
}
function handleEdit(record: any) {
router.push(`/online-design/editor?id=${record.id}`);
}
function handlePreview(record: any) {
router.push(`/online-design/preview?id=${record.id}`);
}
function handleDelete(record: any) {
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const filteredPages = savedPages.filter((p: any) => p.id !== record.id);
localStorage.setItem('online-design-pages', JSON.stringify(filteredPages));
createMessage.success('删除成功');
//
loadData();
}
//
function saveToLocalStorage() {
localStorage.setItem('portal-designs', JSON.stringify(dataSource.value));
}
function loadData() {
loading.value = true;
// localStorage
setTimeout(() => {
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
//
if (savedPages.length === 0) {
const exampleData = [
{
id: '1',
name: '企业官网首页',
description: '公司官方网站首页设计',
status: 1,
createTime: '2024-08-06 10:00:00',
updateTime: '2024-08-06 15:30:00',
},
{
id: '2',
name: '产品展示页',
description: '产品介绍和展示页面',
status: 0,
createTime: '2024-08-06 14:20:00',
updateTime: '2024-08-06 16:45:00',
},
];
localStorage.setItem('online-design-pages', JSON.stringify(exampleData));
dataSource.value = exampleData;
} else {
dataSource.value = savedPages;
}
loading.value = false;
}, 500);
}
onMounted(() => {
loadData();
});
</script>
<style scoped>
.portal-design-page {
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
background: white;
border-bottom: 1px solid #e8e8e8;
padding: 24px 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #262626;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 14px;
color: #8c8c8c;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
}
.portal-grid-container {
max-width: 1400px;
margin: 0 auto;
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.portal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.portal-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
/* 新建卡片样式 */
.create-card {
border: 2px dashed #d9d9d9;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.create-card:hover {
border-color: #1890ff;
background: #f0f9ff;
}
.create-card-content {
text-align: center;
color: #8c8c8c;
}
.create-icon {
font-size: 32px;
margin-bottom: 12px;
color: #d9d9d9;
}
.create-card:hover .create-icon {
color: #1890ff;
}
.create-text {
font-size: 14px;
font-weight: 500;
}
.create-card:hover .create-text {
color: #1890ff;
}
/* 门户缩略图 */
.portal-thumbnail {
position: relative;
height: 140px;
background: #f8f9fa;
overflow: hidden;
}
.thumbnail-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #8c8c8c;
font-size: 32px;
}
.thumbnail-placeholder span {
font-size: 12px;
margin-top: 8px;
font-weight: 500;
}
/* 悬停遮罩 */
.portal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.portal-card:hover .portal-overlay {
opacity: 1;
}
.overlay-actions {
display: flex;
gap: 8px;
}
.overlay-actions .ant-btn {
font-size: 12px;
height: 28px;
padding: 0 12px;
}
/* 卡片信息 */
.portal-info {
padding: 12px 16px;
}
.portal-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
}
.portal-status {
padding: 1px 6px;
border-radius: 10px;
background: #f5f5f5;
color: #8c8c8c;
font-weight: 500;
font-size: 10px;
}
.portal-status.published {
background: #f6ffed;
color: #52c41a;
}
.portal-time {
color: #8c8c8c;
}
/* 操作菜单 */
.portal-actions {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.3s ease;
}
.portal-card:hover .portal-actions {
opacity: 1;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #8c8c8c;
}
.empty-icon {
font-size: 80px;
margin-bottom: 24px;
opacity: 0.5;
}
.empty-text h3 {
font-size: 18px;
color: #262626;
margin-bottom: 8px;
}
.empty-text p {
font-size: 14px;
margin-bottom: 24px;
}
/* 危险操作样式 */
:deep(.danger-item) {
color: #ff4d4f !important;
}
:deep(.danger-item:hover) {
background: #fff2f0 !important;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<div class="test-page">
<h1>在线设计模块测试页面</h1>
<div class="test-section">
<h2>组件测试</h2>
<!-- 轮播图组件测试 -->
<div class="component-test">
<h3>轮播图组件</h3>
<CarouselComponent
:autoplay="true"
:dots="true"
:arrows="true"
:data="{
images: [
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' }
]
}"
style="width: 100%; height: 300px; margin-bottom: 20px;"
/>
</div>
<!-- 文本组件测试 -->
<div class="component-test">
<h3>文本组件</h3>
<TextComponent
tag="h2"
:data="{ content: '这是一个标题文本组件' }"
style="color: #333; font-size: 24px; margin-bottom: 16px;"
/>
<TextComponent
tag="p"
:data="{ content: '这是一个段落文本组件,支持多行文本显示。可以通过属性面板编辑文本内容和样式。' }"
style="color: #666; font-size: 16px; line-height: 1.6; margin-bottom: 16px;"
/>
</div>
<!-- 图片组件测试 -->
<div class="component-test">
<h3>图片组件</h3>
<ImageComponent
alt="测试图片"
:data="{ src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image+Component' }"
style="width: 400px; height: 200px; margin-bottom: 16px;"
/>
</div>
<!-- 按钮组件测试 -->
<div class="component-test">
<h3>按钮组件</h3>
<ButtonComponent
type="primary"
size="large"
:data="{ text: '主要按钮' }"
style="margin-right: 16px; margin-bottom: 16px;"
/>
<ButtonComponent
type="default"
size="middle"
:data="{ text: '默认按钮' }"
style="margin-right: 16px; margin-bottom: 16px;"
/>
<ButtonComponent
type="dashed"
size="small"
:data="{ text: '虚线按钮' }"
style="margin-bottom: 16px;"
/>
</div>
</div>
<div class="test-section">
<h2>功能测试</h2>
<p>请访问 <a href="/online-design/portal" target="_blank">/online-design/portal</a> 测试完整的在线设计功能</p>
</div>
</div>
</template>
<script lang="ts" setup>
import CarouselComponent from './portal/components/design-components/CarouselComponent.vue';
import TextComponent from './portal/components/design-components/TextComponent.vue';
import ImageComponent from './portal/components/design-components/ImageComponent.vue';
import ButtonComponent from './portal/components/design-components/ButtonComponent.vue';
</script>
<style scoped>
.test-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.test-section {
margin-bottom: 40px;
}
.component-test {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fafafa;
}
.component-test h3 {
margin-top: 0;
margin-bottom: 16px;
color: #333;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 40px;
}
h2 {
color: #666;
border-bottom: 2px solid #1890ff;
padding-bottom: 8px;
margin-bottom: 20px;
}
a {
color: #1890ff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>