|
@@ -1,6 +1,6 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
|
-import { Delete, MoreFilled } from '@element-plus/icons-vue'
|
|
|
+import { ref, computed, onUnmounted } from 'vue'
|
|
|
+import { Delete, MoreFilled, Edit } from '@element-plus/icons-vue'
|
|
|
import Markdown from '/@/components/markdown/Markdown.vue'
|
|
|
|
|
|
type MarkdownDashBoard = {
|
|
@@ -20,12 +20,23 @@ const props = defineProps<{
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
updatePosition: [index: number, position: { x: number; y: number }]
|
|
|
+ updateSize: [index: number, size: { w: number; h: number }]
|
|
|
+ updateContent: [index: number, content: { title: string; data: string }]
|
|
|
remove: [index: number]
|
|
|
}>()
|
|
|
|
|
|
const cardRef = ref<HTMLElement>()
|
|
|
const isDragging = ref(false)
|
|
|
+const isResizing = ref(false)
|
|
|
const dragOffset = ref({ x: 0, y: 0 })
|
|
|
+const resizeType = ref<'se' | 'e' | 's' | ''>('')
|
|
|
+const initialSize = ref({ w: 0, h: 0 })
|
|
|
+const initialPosition = ref({ x: 0, y: 0 })
|
|
|
+
|
|
|
+// 编辑相关状态
|
|
|
+const showEditDialog = ref(false)
|
|
|
+const editTitle = ref('')
|
|
|
+const editData = ref('')
|
|
|
|
|
|
// 计算卡片样式
|
|
|
const cardStyle = computed(() => ({
|
|
@@ -37,9 +48,9 @@ const cardStyle = computed(() => ({
|
|
|
zIndex: props.card.z + 100
|
|
|
}))
|
|
|
|
|
|
-// 开始拖拽
|
|
|
+// 开始拖拽移动
|
|
|
const startDrag = (event: MouseEvent) => {
|
|
|
- if (!cardRef.value) return
|
|
|
+ if (!cardRef.value || isResizing.value) return
|
|
|
|
|
|
isDragging.value = true
|
|
|
|
|
@@ -60,7 +71,25 @@ const startDrag = (event: MouseEvent) => {
|
|
|
event.preventDefault()
|
|
|
}
|
|
|
|
|
|
-// 处理拖拽
|
|
|
+// 开始拖拽调整大小
|
|
|
+const startResize = (event: MouseEvent, type: 'se' | 'e' | 's') => {
|
|
|
+ if (!cardRef.value) return
|
|
|
+
|
|
|
+ isResizing.value = true
|
|
|
+ resizeType.value = type
|
|
|
+
|
|
|
+ initialSize.value = { w: props.card.w, h: props.card.h }
|
|
|
+ initialPosition.value = { x: event.clientX, y: event.clientY }
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', handleResize)
|
|
|
+ document.addEventListener('mouseup', stopResize)
|
|
|
+
|
|
|
+ // 防止文本选择和事件冒泡
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+}
|
|
|
+
|
|
|
+// 处理拖拽移动
|
|
|
const handleDrag = (event: MouseEvent) => {
|
|
|
if (!isDragging.value || !cardRef.value) return
|
|
|
|
|
@@ -77,13 +106,73 @@ const handleDrag = (event: MouseEvent) => {
|
|
|
emit('updatePosition', props.index, { x: constrainedX, y: constrainedY })
|
|
|
}
|
|
|
|
|
|
-// 停止拖拽
|
|
|
+// 处理拖拽调整大小
|
|
|
+const handleResize = (event: MouseEvent) => {
|
|
|
+ if (!isResizing.value || !cardRef.value) return
|
|
|
+
|
|
|
+ const parentRect = cardRef.value.parentElement?.getBoundingClientRect()
|
|
|
+ if (!parentRect) return
|
|
|
+
|
|
|
+ const deltaX = event.clientX - initialPosition.value.x
|
|
|
+ const deltaY = event.clientY - initialPosition.value.y
|
|
|
+
|
|
|
+ const deltaXPercent = (deltaX / parentRect.width) * 100
|
|
|
+ const deltaYPercent = (deltaY / parentRect.height) * 100
|
|
|
+
|
|
|
+ let newW = initialSize.value.w
|
|
|
+ let newH = initialSize.value.h
|
|
|
+
|
|
|
+ if (resizeType.value === 'se' || resizeType.value === 'e') {
|
|
|
+ newW = Math.max(15, Math.min(100 - props.card.x, initialSize.value.w + deltaXPercent))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (resizeType.value === 'se' || resizeType.value === 's') {
|
|
|
+ newH = Math.max(10, Math.min(100 - props.card.y, initialSize.value.h + deltaYPercent))
|
|
|
+ }
|
|
|
+
|
|
|
+ emit('updateSize', props.index, { w: newW, h: newH })
|
|
|
+}
|
|
|
+
|
|
|
+// 停止拖拽移动
|
|
|
const stopDrag = () => {
|
|
|
isDragging.value = false
|
|
|
document.removeEventListener('mousemove', handleDrag)
|
|
|
document.removeEventListener('mouseup', stopDrag)
|
|
|
}
|
|
|
|
|
|
+// 停止拖拽调整大小
|
|
|
+const stopResize = () => {
|
|
|
+ isResizing.value = false
|
|
|
+ resizeType.value = ''
|
|
|
+ document.removeEventListener('mousemove', handleResize)
|
|
|
+ document.removeEventListener('mouseup', stopResize)
|
|
|
+}
|
|
|
+
|
|
|
+// 编辑卡片
|
|
|
+const handleEdit = () => {
|
|
|
+ editTitle.value = props.card.title
|
|
|
+ editData.value = props.card.data
|
|
|
+ showEditDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 确认编辑
|
|
|
+const confirmEdit = () => {
|
|
|
+ if (editTitle.value.trim() && editData.value.trim()) {
|
|
|
+ emit('updateContent', props.index, {
|
|
|
+ title: editTitle.value.trim(),
|
|
|
+ data: editData.value.trim()
|
|
|
+ })
|
|
|
+ showEditDialog.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 取消编辑
|
|
|
+const cancelEdit = () => {
|
|
|
+ showEditDialog.value = false
|
|
|
+ editTitle.value = ''
|
|
|
+ editData.value = ''
|
|
|
+}
|
|
|
+
|
|
|
// 删除卡片
|
|
|
const handleRemove = () => {
|
|
|
emit('remove', props.index)
|
|
@@ -93,6 +182,8 @@ const handleRemove = () => {
|
|
|
onUnmounted(() => {
|
|
|
document.removeEventListener('mousemove', handleDrag)
|
|
|
document.removeEventListener('mouseup', stopDrag)
|
|
|
+ document.removeEventListener('mousemove', handleResize)
|
|
|
+ document.removeEventListener('mouseup', stopResize)
|
|
|
})
|
|
|
</script>
|
|
|
|
|
@@ -100,7 +191,7 @@ onUnmounted(() => {
|
|
|
<div
|
|
|
ref="cardRef"
|
|
|
:style="cardStyle"
|
|
|
- :class="['draggable-card', { 'is-dragging': isDragging }]"
|
|
|
+ :class="['draggable-card', { 'is-dragging': isDragging, 'is-resizing': isResizing }]"
|
|
|
>
|
|
|
<el-card class="card-content" shadow="hover">
|
|
|
<!-- 卡片标题栏 - 可拖拽区域 -->
|
|
@@ -121,6 +212,10 @@ onUnmounted(() => {
|
|
|
/>
|
|
|
<template #dropdown>
|
|
|
<el-dropdown-menu>
|
|
|
+ <el-dropdown-item @click="handleEdit">
|
|
|
+ <el-icon><Edit /></el-icon>
|
|
|
+ <span>编辑</span>
|
|
|
+ </el-dropdown-item>
|
|
|
<el-dropdown-item @click="handleRemove">
|
|
|
<el-icon><Delete /></el-icon>
|
|
|
<span>删除</span>
|
|
@@ -141,6 +236,83 @@ onUnmounted(() => {
|
|
|
/>
|
|
|
</div>
|
|
|
</el-card>
|
|
|
+
|
|
|
+ <!-- 调整大小的拖拽手柄 -->
|
|
|
+ <div class="resize-handles">
|
|
|
+ <!-- 右边缘 -->
|
|
|
+ <div
|
|
|
+ class="resize-handle resize-handle-e"
|
|
|
+ @mousedown="startResize($event, 'e')"
|
|
|
+ ></div>
|
|
|
+
|
|
|
+ <!-- 底边缘 -->
|
|
|
+ <div
|
|
|
+ class="resize-handle resize-handle-s"
|
|
|
+ @mousedown="startResize($event, 's')"
|
|
|
+ ></div>
|
|
|
+
|
|
|
+ <!-- 右下角 -->
|
|
|
+ <div
|
|
|
+ class="resize-handle resize-handle-se"
|
|
|
+ @mousedown="startResize($event, 'se')"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 编辑对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="showEditDialog"
|
|
|
+ title="编辑卡片内容"
|
|
|
+ width="600px"
|
|
|
+ :before-close="cancelEdit"
|
|
|
+ append-to-body
|
|
|
+ >
|
|
|
+ <div class="edit-form">
|
|
|
+ <div class="form-item">
|
|
|
+ <label class="form-label">卡片标题</label>
|
|
|
+ <el-input
|
|
|
+ v-model="editTitle"
|
|
|
+ placeholder="请输入卡片标题"
|
|
|
+ maxlength="50"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-item">
|
|
|
+ <label class="form-label">卡片内容 (支持Markdown)</label>
|
|
|
+ <el-input
|
|
|
+ v-model="editData"
|
|
|
+ type="textarea"
|
|
|
+ placeholder="请输入卡片内容,支持Markdown语法"
|
|
|
+ :rows="8"
|
|
|
+ resize="vertical"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="preview-section">
|
|
|
+ <label class="form-label">预览效果</label>
|
|
|
+ <div class="preview-container">
|
|
|
+ <Markdown
|
|
|
+ :content="editData || '暂无内容'"
|
|
|
+ :plugins="[]"
|
|
|
+ class="preview-content"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="cancelEdit">取消</el-button>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ @click="confirmEdit"
|
|
|
+ :disabled="!editTitle.trim() || !editData.trim()"
|
|
|
+ >
|
|
|
+ 确定
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
@@ -153,6 +325,18 @@ onUnmounted(() => {
|
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ &.is-resizing {
|
|
|
+ .card-content {
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ .resize-handles {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
.card-content {
|
|
@@ -253,6 +437,117 @@ onUnmounted(() => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/* 调整大小手柄样式 */
|
|
|
+.resize-handles {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle {
|
|
|
+ position: absolute;
|
|
|
+ pointer-events: all;
|
|
|
+ background: var(--el-color-primary);
|
|
|
+ transition: all 0.2s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: var(--el-color-primary-light-3);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-e {
|
|
|
+ top: 0;
|
|
|
+ right: -2px;
|
|
|
+ bottom: 0;
|
|
|
+ width: 4px;
|
|
|
+ cursor: e-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-s {
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: -2px;
|
|
|
+ height: 4px;
|
|
|
+ cursor: s-resize;
|
|
|
+}
|
|
|
+
|
|
|
+.resize-handle-se {
|
|
|
+ right: -2px;
|
|
|
+ bottom: -2px;
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ cursor: se-resize;
|
|
|
+ border-radius: 0 0 4px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 编辑对话框样式 */
|
|
|
+.edit-form {
|
|
|
+ .form-item {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .form-label {
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview-section {
|
|
|
+ margin-top: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview-container {
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 16px;
|
|
|
+ background: var(--el-fill-color-extra-light);
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview-content {
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+
|
|
|
+ :deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
|
|
+ margin-top: 16px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+
|
|
|
+ &:first-child {
|
|
|
+ margin-top: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(p) {
|
|
|
+ margin-bottom: 12px;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(blockquote) {
|
|
|
+ margin: 12px 0;
|
|
|
+ padding: 12px 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(table) {
|
|
|
+ margin: 12px 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.dialog-footer {
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
/* 下拉菜单样式 */
|
|
|
:deep(.el-dropdown-menu) {
|
|
|
.el-dropdown-menu__item {
|
|
@@ -275,6 +570,12 @@ onUnmounted(() => {
|
|
|
background: var(--el-fill-color-light);
|
|
|
|
|
|
.el-icon {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover:has(.el-icon:contains('Delete')) {
|
|
|
+ .el-icon {
|
|
|
color: var(--el-color-danger);
|
|
|
}
|
|
|
}
|