浏览代码

仪表盘,编辑页面,初步完成

kagg886 1 月之前
父节点
当前提交
0da4061396

+ 362 - 0
src/components/assistant/ComponentLibrary.vue

@@ -0,0 +1,362 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Plus, Document } from '@element-plus/icons-vue'
+import Markdown from '/@/components/markdown/Markdown.vue'
+
+const emit = defineEmits<{
+	addCard: [cardData: { title: string; data: string }]
+}>()
+
+// 组件库数据
+const componentLibrary = ref([
+	{
+		id: 'quote-block',
+		title: '引用块',
+		icon: Document,
+		description: '用于显示引用内容',
+		data: `> ### 重要提示
+>
+> 这是一个引用块示例,可以用来显示重要信息、提示或者引用其他内容。
+>
+> 支持多行内容和**格式化文本**。`,
+		preview: `> 这是一个引用块示例\n> 可以包含重要信息`
+	},
+	{
+		id: 'data-table',
+		title: '数据表格',
+		icon: Document,
+		description: '用于显示结构化数据',
+		data: `| 指标名称 | 当前值 | 目标值 | 完成率 |
+|----------|--------|--------|--------|
+| 用户注册 | 1,234 | 1,500 | 82.3% |
+| 活跃用户 | 856 | 1,000 | 85.6% |
+| 转化率 | 12.5% | 15% | 83.3% |
+| 收入 | ¥45,678 | ¥50,000 | 91.4% |`,
+		preview: `| 指标 | 数值 | 状态 |\n|------|------|------|\n| 用户 | 1,234 | 正常 |`
+	}
+])
+
+// 拖拽开始
+const handleDragStart = (event: DragEvent, component: any) => {
+	if (!event.dataTransfer) return
+
+	event.dataTransfer.setData('text/plain', JSON.stringify({
+		title: component.title,
+		data: component.data
+	}))
+
+	event.dataTransfer.effectAllowed = 'copy'
+}
+
+// 添加组件到画布
+const addComponent = (component: any) => {
+	emit('addCard', {
+		title: component.title,
+		data: component.data
+	})
+}
+
+// 预览组件
+const previewComponent = ref<any>(null)
+const showPreview = ref(false)
+
+const openPreview = (component: any) => {
+	previewComponent.value = component
+	showPreview.value = true
+}
+</script>
+
+<template>
+	<div class="component-library">
+		<div class="library-header">
+			<h3>组件库</h3>
+			<div class="library-info">
+				<span>{{ componentLibrary.length }} 个组件</span>
+			</div>
+		</div>
+
+		<div class="library-content">
+			<div class="component-list">
+				<div
+					v-for="component in componentLibrary"
+					:key="component.id"
+					class="component-item"
+					draggable="true"
+					@dragstart="handleDragStart($event, component)"
+				>
+					<div class="component-header">
+						<div class="component-icon">
+							<el-icon :size="20">
+								<component :is="component.icon" />
+							</el-icon>
+						</div>
+						<div class="component-info">
+							<h4 class="component-title">{{ component.title }}</h4>
+							<p class="component-description">{{ component.description }}</p>
+						</div>
+					</div>
+
+					<div class="component-preview">
+						<Markdown
+							:content="component.preview"
+							:plugins="[]"
+							class="preview-content"
+						/>
+					</div>
+
+					<div class="component-actions">
+						<el-button
+							type="primary"
+							size="small"
+							:icon="Plus"
+							@click="addComponent(component)"
+							class="add-btn"
+						>
+							添加到画布
+						</el-button>
+						<el-button
+							type="text"
+							size="small"
+							@click="openPreview(component)"
+							class="preview-btn"
+						>
+							预览
+						</el-button>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<!-- 使用说明 -->
+		<div class="library-footer">
+			<div class="usage-tips">
+				<h4>使用说明</h4>
+				<ul>
+					<li>拖拽组件到左侧画布</li>
+					<li>或点击"添加到画布"按钮</li>
+					<li>在画布中拖拽标题栏调整位置</li>
+				</ul>
+			</div>
+		</div>
+
+		<!-- 预览对话框 -->
+		<el-dialog
+			v-model="showPreview"
+			:title="`预览 - ${previewComponent?.title}`"
+			width="600px"
+			v-if="previewComponent"
+		>
+			<div class="preview-dialog-content">
+				<Markdown
+					:content="previewComponent.data"
+					:plugins="[]"
+					class="full-preview-content"
+				/>
+			</div>
+			<template #footer>
+				<div class="dialog-footer">
+					<el-button @click="showPreview = false">关闭</el-button>
+					<el-button
+						type="primary"
+						@click="addComponent(previewComponent); showPreview = false"
+					>
+						添加到画布
+					</el-button>
+				</div>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.component-library {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.library-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 20px;
+	border-bottom: 1px solid var(--el-border-color-light);
+	background: var(--el-fill-color-extra-light);
+
+	h3 {
+		margin: 0;
+		color: var(--el-text-color-primary);
+		font-size: 16px;
+		font-weight: 600;
+	}
+
+	.library-info {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+}
+
+.library-content {
+	flex: 1;
+	overflow-y: auto;
+	padding: 16px;
+}
+
+.component-list {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.component-item {
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 8px;
+	background: var(--el-bg-color);
+	transition: all 0.2s ease;
+	cursor: grab;
+
+	&:hover {
+		border-color: var(--el-color-primary-light-7);
+		box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+	}
+
+	&:active {
+		cursor: grabbing;
+	}
+}
+
+.component-header {
+	display: flex;
+	align-items: flex-start;
+	gap: 12px;
+	padding: 16px 16px 12px 16px;
+}
+
+.component-icon {
+	flex-shrink: 0;
+	width: 40px;
+	height: 40px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: var(--el-color-primary-light-9);
+	border-radius: 8px;
+	color: var(--el-color-primary);
+}
+
+.component-info {
+	flex: 1;
+	min-width: 0;
+}
+
+.component-title {
+	margin: 0 0 4px 0;
+	font-size: 14px;
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+}
+
+.component-description {
+	margin: 0;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	line-height: 1.4;
+}
+
+.component-preview {
+	padding: 0 16px 12px 16px;
+	border-top: 1px solid var(--el-border-color-extra-light);
+	margin-top: 8px;
+	padding-top: 12px;
+}
+
+.preview-content {
+	font-size: 12px;
+	line-height: 1.4;
+	max-height: 80px;
+	overflow: hidden;
+
+	:deep(blockquote) {
+		margin: 4px 0;
+		padding: 6px 10px;
+		font-size: 11px;
+	}
+
+	:deep(table) {
+		font-size: 10px;
+		margin: 4px 0;
+	}
+
+	:deep(th), :deep(td) {
+		padding: 2px 6px;
+	}
+}
+
+.component-actions {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 12px 16px;
+	border-top: 1px solid var(--el-border-color-extra-light);
+	background: var(--el-fill-color-extra-light);
+	border-radius: 0 0 8px 8px;
+}
+
+.add-btn {
+	flex: 1;
+	margin-right: 8px;
+}
+
+.preview-btn {
+	color: var(--el-text-color-regular);
+
+	&:hover {
+		color: var(--el-color-primary);
+	}
+}
+
+.library-footer {
+	border-top: 1px solid var(--el-border-color-light);
+	padding: 16px 20px;
+	background: var(--el-fill-color-extra-light);
+}
+
+.usage-tips {
+	h4 {
+		margin: 0 0 8px 0;
+		font-size: 14px;
+		font-weight: 600;
+		color: var(--el-text-color-primary);
+	}
+
+	ul {
+		margin: 0;
+		padding-left: 16px;
+
+		li {
+			font-size: 12px;
+			color: var(--el-text-color-secondary);
+			line-height: 1.5;
+			margin-bottom: 4px;
+		}
+	}
+}
+
+.preview-dialog-content {
+	max-height: 400px;
+	overflow-y: auto;
+	padding: 16px;
+	border: 1px solid var(--el-border-color-light);
+	border-radius: 6px;
+	background: var(--el-fill-color-extra-light);
+}
+
+.full-preview-content {
+	font-size: 14px;
+	line-height: 1.6;
+}
+
+.dialog-footer {
+	text-align: right;
+}
+</style>

+ 195 - 0
src/components/assistant/DashboardDesigner.vue

@@ -0,0 +1,195 @@
+<script setup lang="ts">
+import { ref, computed, nextTick } from 'vue'
+import DraggableCard from './DraggableCard.vue'
+import { Delete, Document } from '@element-plus/icons-vue'
+
+type MarkdownDashBoard = {
+	x: number
+	y: number
+	w: number
+	h: number
+	z: number
+	title: string
+	data: string
+}
+
+const props = defineProps<{
+	cards: MarkdownDashBoard[]
+}>()
+
+const emit = defineEmits<{
+	updatePosition: [index: number, position: { x: number; y: number }]
+	removeCard: [index: number]
+	addCard: [cardData: { title: string; data: string; x: number; y: number }]
+}>()
+
+const designerContainer = ref<HTMLElement>()
+
+// 处理卡片位置更新
+const handleCardPositionUpdate = (index: number, position: { x: number; y: number }) => {
+	emit('updatePosition', index, position)
+}
+
+// 处理卡片删除
+const handleCardRemove = (index: number) => {
+	emit('removeCard', index)
+}
+
+// 处理拖拽放置
+const handleDrop = (event: DragEvent) => {
+	event.preventDefault()
+
+	if (!designerContainer.value) return
+
+	const rect = designerContainer.value.getBoundingClientRect()
+	const x = ((event.clientX - rect.left) / rect.width) * 100
+	const y = ((event.clientY - rect.top) / rect.height) * 100
+
+	// 从拖拽数据中获取组件信息
+	const dragData = event.dataTransfer?.getData('text/plain')
+	if (dragData) {
+		try {
+			const componentData = JSON.parse(dragData)
+			// 限制在画布范围内
+			const constrainedX = Math.max(0, Math.min(70, x)) // 预留30%宽度
+			const constrainedY = Math.max(0, Math.min(75, y)) // 预留25%高度
+
+			emit('addCard', {
+				...componentData,
+				x: constrainedX,
+				y: constrainedY
+			})
+		} catch (error) {
+			console.error('Invalid drag data:', error)
+		}
+	}
+}
+
+const handleDragOver = (event: DragEvent) => {
+	event.preventDefault()
+}
+</script>
+
+<template>
+	<div class="dashboard-designer">
+		<div class="designer-header">
+			<h3>设计画布</h3>
+			<div class="designer-info">
+				<span>{{ cards.length }} 个组件</span>
+			</div>
+		</div>
+
+		<div
+			ref="designerContainer"
+			class="designer-canvas"
+			@drop="handleDrop"
+			@dragover="handleDragOver"
+		>
+			<!-- 网格背景 -->
+			<div class="grid-background"></div>
+
+			<!-- 渲染所有卡片 -->
+			<DraggableCard
+				v-for="(card, index) in cards"
+				:key="`card-${index}`"
+				:card="card"
+				:index="index"
+				@update-position="handleCardPositionUpdate"
+				@remove="handleCardRemove"
+			/>
+
+			<!-- 空状态提示 -->
+			<div v-if="cards.length === 0" class="empty-canvas">
+				<div class="empty-icon">
+					<el-icon :size="60" color="#d1d5db">
+						<Document />
+					</el-icon>
+				</div>
+				<div class="empty-text">
+					<h3>开始设计您的仪表板</h3>
+					<p>从右侧组件库拖拽组件到此处</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.dashboard-designer {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.designer-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 20px;
+	border-bottom: 1px solid var(--el-border-color-light);
+	background: var(--el-fill-color-extra-light);
+
+	h3 {
+		margin: 0;
+		color: var(--el-text-color-primary);
+		font-size: 16px;
+		font-weight: 600;
+	}
+
+	.designer-info {
+		font-size: 14px;
+		color: var(--el-text-color-regular);
+	}
+}
+
+.designer-canvas {
+	flex: 1;
+	position: relative;
+	overflow: hidden;
+	background: var(--el-bg-color-page);
+	min-height: 500px;
+}
+
+.grid-background {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-image:
+		linear-gradient(to right, var(--el-border-color-extra-light) 1px, transparent 1px),
+		linear-gradient(to bottom, var(--el-border-color-extra-light) 1px, transparent 1px);
+	background-size: 20px 20px;
+	pointer-events: none;
+	opacity: 0.5;
+}
+
+.empty-canvas {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	text-align: center;
+	color: var(--el-text-color-secondary);
+
+	.empty-icon {
+		margin-bottom: 16px;
+		opacity: 0.6;
+	}
+
+	.empty-text {
+		h3 {
+			margin: 0 0 8px 0;
+			font-size: 18px;
+			font-weight: 500;
+			color: var(--el-text-color-regular);
+		}
+
+		p {
+			margin: 0;
+			font-size: 14px;
+			color: var(--el-text-color-secondary);
+		}
+	}
+}
+</style>

+ 283 - 0
src/components/assistant/DraggableCard.vue

@@ -0,0 +1,283 @@
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { Delete, MoreFilled } from '@element-plus/icons-vue'
+import Markdown from '/@/components/markdown/Markdown.vue'
+
+type MarkdownDashBoard = {
+	x: number
+	y: number
+	w: number
+	h: number
+	z: number
+	title: string
+	data: string
+}
+
+const props = defineProps<{
+	card: MarkdownDashBoard
+	index: number
+}>()
+
+const emit = defineEmits<{
+	updatePosition: [index: number, position: { x: number; y: number }]
+	remove: [index: number]
+}>()
+
+const cardRef = ref<HTMLElement>()
+const isDragging = ref(false)
+const dragOffset = ref({ x: 0, y: 0 })
+
+// 计算卡片样式
+const cardStyle = computed(() => ({
+	position: 'absolute' as const,
+	left: `${props.card.x}%`,
+	top: `${props.card.y}%`,
+	width: `${props.card.w}%`,
+	height: `${props.card.h}%`,
+	zIndex: props.card.z + 100
+}))
+
+// 开始拖拽
+const startDrag = (event: MouseEvent) => {
+	if (!cardRef.value) return
+
+	isDragging.value = true
+
+	const rect = cardRef.value.getBoundingClientRect()
+	const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
+
+	if (!parentRect) return
+
+	dragOffset.value = {
+		x: event.clientX - rect.left,
+		y: event.clientY - rect.top
+	}
+
+	document.addEventListener('mousemove', handleDrag)
+	document.addEventListener('mouseup', stopDrag)
+
+	// 防止文本选择
+	event.preventDefault()
+}
+
+// 处理拖拽
+const handleDrag = (event: MouseEvent) => {
+	if (!isDragging.value || !cardRef.value) return
+
+	const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
+	if (!parentRect) return
+
+	const newX = ((event.clientX - dragOffset.value.x - parentRect.left) / parentRect.width) * 100
+	const newY = ((event.clientY - dragOffset.value.y - parentRect.top) / parentRect.height) * 100
+
+	// 限制在画布范围内
+	const constrainedX = Math.max(0, Math.min(100 - props.card.w, newX))
+	const constrainedY = Math.max(0, Math.min(100 - props.card.h, newY))
+
+	emit('updatePosition', props.index, { x: constrainedX, y: constrainedY })
+}
+
+// 停止拖拽
+const stopDrag = () => {
+	isDragging.value = false
+	document.removeEventListener('mousemove', handleDrag)
+	document.removeEventListener('mouseup', stopDrag)
+}
+
+// 删除卡片
+const handleRemove = () => {
+	emit('remove', props.index)
+}
+
+// 清理事件监听器
+onUnmounted(() => {
+	document.removeEventListener('mousemove', handleDrag)
+	document.removeEventListener('mouseup', stopDrag)
+})
+</script>
+
+<template>
+	<div
+		ref="cardRef"
+		:style="cardStyle"
+		:class="['draggable-card', { 'is-dragging': isDragging }]"
+	>
+		<el-card class="card-content" shadow="hover">
+			<!-- 卡片标题栏 - 可拖拽区域 -->
+			<template #header>
+				<div
+					class="card-header"
+					@mousedown="startDrag"
+				>
+					<span class="card-title">{{ card.title }}</span>
+					<div class="card-actions">
+						<el-dropdown trigger="click" placement="bottom-end">
+							<el-button
+								type="text"
+								:icon="MoreFilled"
+								size="small"
+								class="action-btn"
+								@click.stop
+							/>
+							<template #dropdown>
+								<el-dropdown-menu>
+									<el-dropdown-item @click="handleRemove">
+										<el-icon><Delete /></el-icon>
+										<span>删除</span>
+									</el-dropdown-item>
+								</el-dropdown-menu>
+							</template>
+						</el-dropdown>
+					</div>
+				</div>
+			</template>
+
+			<!-- 卡片内容 -->
+			<div class="card-body">
+				<Markdown
+					:content="card.data"
+					:plugins="[]"
+					class="markdown-content"
+				/>
+			</div>
+		</el-card>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.draggable-card {
+	transition: box-shadow 0.2s ease;
+
+	&.is-dragging {
+		.card-content {
+			box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
+		}
+	}
+}
+
+.card-content {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+
+	:deep(.el-card__header) {
+		padding: 12px 16px;
+		border-bottom: 1px solid var(--el-border-color-lighter);
+		background: var(--el-fill-color-extra-light);
+	}
+
+	:deep(.el-card__body) {
+		flex: 1;
+		padding: 16px;
+		overflow: auto;
+	}
+}
+
+.card-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	cursor: move;
+	user-select: none;
+
+	&:hover {
+		background: var(--el-fill-color-light);
+		margin: -12px -16px;
+		padding: 12px 16px;
+		border-radius: 4px;
+	}
+}
+
+.card-title {
+	font-weight: 600;
+	color: var(--el-text-color-primary);
+	font-size: 14px;
+}
+
+.card-actions {
+	display: flex;
+	align-items: center;
+	gap: 4px;
+	opacity: 0;
+	transition: opacity 0.2s ease;
+}
+
+.card-header:hover .card-actions {
+	opacity: 1;
+}
+
+.action-btn {
+	width: 24px !important;
+	height: 24px !important;
+	padding: 0 !important;
+	color: var(--el-text-color-regular);
+
+	&:hover {
+		color: var(--el-color-primary);
+		background: var(--el-color-primary-light-9) !important;
+	}
+}
+
+.card-body {
+	height: 100%;
+	overflow: auto;
+}
+
+.markdown-content {
+	font-size: 13px;
+	line-height: 1.5;
+
+	:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
+		margin-top: 12px;
+		margin-bottom: 8px;
+		font-size: 14px;
+	}
+
+	:deep(p) {
+		margin-bottom: 8px;
+	}
+
+	:deep(blockquote) {
+		margin: 8px 0;
+		padding: 8px 12px;
+		font-size: 12px;
+	}
+
+	:deep(table) {
+		font-size: 12px;
+		margin: 8px 0;
+	}
+
+	:deep(th), :deep(td) {
+		padding: 4px 8px;
+	}
+}
+
+/* 下拉菜单样式 */
+:deep(.el-dropdown-menu) {
+	.el-dropdown-menu__item {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		padding: 8px 16px;
+
+		.el-icon {
+			font-size: 14px;
+			color: var(--el-text-color-regular);
+		}
+
+		span {
+			font-size: 14px;
+			color: var(--el-text-color-primary);
+		}
+
+		&:hover {
+			background: var(--el-fill-color-light);
+
+			.el-icon {
+				color: var(--el-color-danger);
+			}
+		}
+	}
+}
+</style>

+ 156 - 15
src/views/assistant/dashboard/edit.vue

@@ -1,5 +1,9 @@
 <script setup lang="ts">
 import { useLoading } from '/@/utils/loading-util'
+import { computed, ref } from 'vue'
+import DashboardDesigner from '/@/components/assistant/DashboardDesigner.vue'
+import ComponentLibrary from '/@/components/assistant/ComponentLibrary.vue'
+import { ElMessage } from 'element-plus'
 
 type MarkdownDashBoard = {
 	x: number
@@ -12,30 +16,167 @@ type MarkdownDashBoard = {
 	data: string
 }
 
-const props = defineProps<{
-	id?: number
-}>()
-
-import { computed, ref } from 'vue'
-
-const data = ref<MarkdownDashBoard[]>([])
+// 预留props,暂时不使用
+// const props = defineProps<{
+// 	id?: number
+// }>()
 
-const { loading: loadingDashboard, doLoading: doLoadingDashBoard } = useLoading(async () => {
-	if (props.id === undefined) {
-		return '[]'
+const data = ref<MarkdownDashBoard[]>([
+	// 示例数据
+	{
+		x: 10,
+		y: 10,
+		w: 40,
+		h: 30,
+		z: 1,
+		title: '示例卡片1',
+		data: '> 这是一个引用块示例\n> \n> 可以包含多行内容'
+	},
+	{
+		x: 55,
+		y: 15,
+		w: 35,
+		h: 25,
+		z: 2,
+		title: '示例卡片2',
+		data: '| 列1 | 列2 | 列3 |\n|-----|-----|-----|\n| 数据1 | 数据2 | 数据3 |\n| 数据4 | 数据5 | 数据6 |'
 	}
-	//TODO fetch remote
-})
+])
+
+// 预留加载功能,暂时不使用
+// const { loading: loadingDashboard, doLoading: doLoadingDashBoard } = useLoading(async () => {
+// 	if (props.id === undefined) {
+// 		return
+// 	}
+// 	//TODO fetch remote
+// })
 
 const renderer = computed<MarkdownDashBoard[]>(() => [...data.value].sort((a, b) => a.z - b.z))
 
 const { loading: loadingDashboardSubmit, doLoading: doLoadingDashboardSubmit } = useLoading(async () => {
-	//TODO submit
+	try {
+		// TODO: 实际的保存逻辑
+		// 这里应该调用API保存data.value到后端
+		ElMessage.success('仪表板保存成功')
+	} catch (error) {
+		ElMessage.error('保存失败')
+		throw error
+	}
 })
+
+// 添加新卡片
+const addCard = (cardData: { title: string; data: string; x?: number; y?: number }) => {
+	const newCard: MarkdownDashBoard = {
+		x: cardData.x ?? Math.random() * 50, // 使用传入位置或随机位置
+		y: cardData.y ?? Math.random() * 50,
+		w: 30,
+		h: 25,
+		z: Math.max(...data.value.map(item => item.z), 0) + 1,
+		title: cardData.title,
+		data: cardData.data
+	}
+	data.value.push(newCard)
+}
+
+// 更新卡片位置
+const updateCardPosition = (index: number, position: { x: number; y: number }) => {
+	if (data.value[index]) {
+		data.value[index].x = position.x
+		data.value[index].y = position.y
+	}
+}
+
+// 删除卡片
+const removeCard = (index: number) => {
+	data.value.splice(index, 1)
+}
 </script>
 
 <template>
-	<div>edit</div>
+	<div class="dashboard-edit-container">
+		<!-- 顶部工具栏 -->
+		<div class="toolbar">
+			<div class="toolbar-left">
+				<h2>仪表板设计器</h2>
+			</div>
+			<div class="toolbar-right">
+				<el-button
+					type="primary"
+					@click="doLoadingDashboardSubmit"
+					:loading="loadingDashboardSubmit"
+				>
+					保存仪表板
+				</el-button>
+			</div>
+		</div>
+
+		<!-- 主要内容区域 -->
+		<div class="main-content">
+			<!-- 左侧设计器面板 -->
+			<div class="designer-panel">
+				<DashboardDesigner
+					:cards="renderer"
+					@update-position="updateCardPosition"
+					@remove-card="removeCard"
+					@add-card="addCard"
+				/>
+			</div>
+
+			<!-- 右侧组件库面板 -->
+			<div class="library-panel">
+				<ComponentLibrary @add-card="addCard" />
+			</div>
+		</div>
+	</div>
 </template>
 
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+.dashboard-edit-container {
+	height: 100vh;
+	display: flex;
+	flex-direction: column;
+	background: var(--el-bg-color-page);
+}
+
+.toolbar {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 16px 24px;
+	background: var(--el-bg-color);
+	border-bottom: 1px solid var(--el-border-color-light);
+	box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+
+	.toolbar-left {
+		h2 {
+			margin: 0;
+			color: var(--el-text-color-primary);
+			font-size: 18px;
+			font-weight: 600;
+		}
+	}
+
+	.toolbar-right {
+		display: flex;
+		gap: 12px;
+	}
+}
+
+.main-content {
+	flex: 1;
+	display: flex;
+	overflow: hidden;
+}
+
+.designer-panel {
+	flex: 1;
+	background: var(--el-bg-color);
+	border-right: 1px solid var(--el-border-color-light);
+}
+
+.library-panel {
+	width: 300px;
+	background: var(--el-bg-color);
+	border-left: 1px solid var(--el-border-color-light);
+}
+</style>