Quellcode durchsuchen

1. 支持媒资组件;2. 优化文本组件;3. 支持滚动文本组件;4. 支持侧边栏组件拖拽重排

Shinohara Haruna vor 5 Monaten
Ursprung
Commit
856eb9fab0

+ 130 - 29
smsb-plus-ui/src/views/smsb/itemProgram/EditProgram.vue

@@ -9,14 +9,18 @@
         </el-icon>
       </el-button>
       <div class="sidebar-title">组件</div>
-      <template v-for="item in editorContent.elements.slice().sort((a, b) => b.depth - a.depth)"
+      <template v-for="(item, idx) in editorContent.elements.slice().sort((a, b) => b.depth - a.depth)"
         :key="item.depth + '-' + item.type">
         <div class="sidebar-item component-item"
           :class="{ 'selected': selectedComponent === item || (item.type === 'canvas' && selectedComponent && selectedComponent.type === 'canvas') }"
-          @click="item.type === 'canvas' ? selectCanvasFromSidebar() : selectComponent(item)">
+          @click="item.type === 'canvas' ? selectCanvasFromSidebar() : selectComponent(item)" draggable="true"
+          @dragstart="onSidebarDragStart(item, idx)" @dragover.prevent="onSidebarDragOver(item, idx, $event)"
+          @drop.prevent="onSidebarDrop(item, idx, $event)">
           <div class="component-icon-text">
             <template v-if="item.type === 'canvas'">画布</template>
             <template v-else-if="item.type === 'text'">文本</template>
+            <template v-else-if="item.type === 'scrollingText'">滚动文本</template>
+            <template v-else-if="item.type === 'mediaAsset'">媒资</template>
             <!-- 未来可扩展图片等类型 -->
           </div>
         </div>
@@ -27,6 +31,8 @@
     <div class="main-editor">
       <div class="toolbar">
         <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('text')">文本</div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('scrollingText')">滚动文本</div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('mediaAsset')">媒资</div>
         <!-- 可扩展更多组件 -->
       </div>
       <div class="editor-canvas" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
@@ -45,8 +51,28 @@
             zIndex: 10
           }" @mousedown="onElementMouseDown($event, item)">
             <TextBoard v-if="item.type === 'text'" :text="item.text" :color="item.color" :font-size="item.fontSize"
-              :font-weight="item.fontWeight" :align="item.align" @click.stop="selectComponent(item)"
-              :class="{ selected: selectedComponent === item }" />
+              :font-weight="item.fontWeight" :align="item.align" :width="item.width" :height="item.height"
+              :selected="selectedComponent === item" @resize="
+                ({ width, height }) => {
+                  item.width = width;
+                  item.height = height;
+                }
+              " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+            <ScrollingTextBoard v-if="item.type === 'scrollingText'" :text="item.text" :color="item.color"
+              :font-size="item.fontSize" :font-weight="item.fontWeight" :align="item.align" :width="item.width"
+              :height="item.height" :speed="item.speed" :selected="selectedComponent === item" @resize="
+                ({ width, height }) => {
+                  item.width = width;
+                  item.height = height;
+                }
+              " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+            <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width" :height="item.height"
+              :media-id="item.mediaId" :selected="selectedComponent === item" @resize="
+                ({ width, height }) => {
+                  item.width = width;
+                  item.height = height;
+                }
+              " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
             <!-- 未来可扩展更多类型 -->
           </div>
         </template>
@@ -85,10 +111,52 @@
 </template>
 
 <script setup lang="ts">
+// 拖拽排序相关
+const sidebarDrag = ref<{ item: any; idx: number } | null>(null);
+
+function onSidebarDragStart(item: any, idx: number) {
+  sidebarDrag.value = { item, idx };
+}
+function onSidebarDragOver(targetItem: any, targetIdx: number, e: DragEvent) {
+  e.preventDefault();
+}
+function onSidebarDrop(targetItem: any, targetIdx: number, e: DragEvent) {
+  if (!sidebarDrag.value) return;
+  const elements = editorContent.value.elements;
+  // 排序前先按 depth 降序
+  const sorted = elements.slice().sort((a, b) => b.depth - a.depth);
+  const fromIdx = sorted.findIndex((el) => el === sidebarDrag.value!.item);
+  const toIdx = sorted.findIndex((el) => el === targetItem);
+  if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
+    sidebarDrag.value = null;
+    return;
+  }
+  // 交换depth
+  const fromDepth = sorted[fromIdx].depth;
+  const toDepth = sorted[toIdx].depth;
+  sorted[fromIdx].depth = toDepth;
+  sorted[toIdx].depth = fromDepth;
+  // 重新赋值到原数组
+  for (let i = 0; i < sorted.length; i++) {
+    const origIdx = elements.findIndex((el) => el === sorted[i]);
+    if (origIdx !== -1) elements[origIdx].depth = sorted[i].depth;
+  }
+  sidebarDrag.value = null;
+}
+
 import { ref, onMounted, computed, nextTick } from 'vue';
 import CanvasBoard from './component/CanvasBoard.vue';
 import TextBoard from './component/TextBoard.vue';
+import ScrollingTextBoard from './component/ScrollingTextBoard.vue';
+import MediaAssetBoard from './component/MediaAssetBoard.vue';
 import { canvasPropNameMap } from './component/propNameMaps';
+// 拖拽类型
+const dragType = ref<string | null>(null);
+// 获取最大 depth
+function getMaxDepth() {
+  if (!editorContent.value.elements.length) return 0;
+  return Math.max(...editorContent.value.elements.map((el: any) => el.depth || 0));
+}
 import { useRoute, useRouter } from 'vue-router';
 import { ElMessage } from 'element-plus';
 import { ArrowLeft } from '@element-plus/icons-vue';
@@ -119,10 +187,20 @@ function showEditableProp(key: string) {
 }
 
 // 获取属性中文名
+import { textPropNameMap, scrollingTextPropNameMap, mediaAssetPropNameMap } from './component/propNameMaps';
 function getPropLabel(key: string) {
   if (selectedComponent.value?.type === 'canvas') {
     return canvasPropNameMap[key] || key;
   }
+  if (selectedComponent.value?.type === 'text') {
+    return textPropNameMap[key] || key;
+  }
+  if (selectedComponent.value?.type === 'scrollingText') {
+    return scrollingTextPropNameMap[key] || key;
+  }
+  if (selectedComponent.value?.type === 'mediaAsset') {
+    return mediaAssetPropNameMap[key] || key;
+  }
   return key;
 }
 
@@ -241,38 +319,61 @@ function onElementMouseUp() {
 }
 
 function onToolbarDragStart(type: string) {
-  dragComponentType.value = type;
+  dragType.value = type;
 }
 
 function onCanvasDrop(e: DragEvent) {
-  if (!dragComponentType.value) return;
-  // 计算相对画布的坐标(此处简单居中,后续可完善为鼠标点)
-  const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
-  let x = 50,
-    y = 50;
-  if (canvas && editorCanvasRef.value) {
-    // 计算鼠标在容器中的位置,考虑缩放
-    const rect = editorCanvasRef.value.getBoundingClientRect();
-    const scale = canvasScale.value || 1;
-    x = (e.clientX - rect.left) / scale - (canvas.x || 0);
-    y = (e.clientY - rect.top) / scale - (canvas.y || 0);
-  }
-  if (dragComponentType.value === 'text') {
-    editorContent.value.elements.push({
+  if (!dragType.value) return;
+  const rect = editorCanvasRef.value?.getBoundingClientRect();
+  const x = e.clientX - (rect?.left || 0);
+  const y = e.clientY - (rect?.top || 0);
+  if (dragType.value === 'text') {
+    const newText = {
       type: 'text',
       text: '新文本',
       color: '#222',
       fontSize: 24,
       fontWeight: 'normal',
       align: 'center',
-      x,
-      y,
+      x: x,
+      y: y,
       width: 200,
       height: 40,
-      depth: editorContent.value.elements.length
-    });
+      depth: getMaxDepth() + 1
+    };
+    editorContent.value.elements.push(newText);
+    nextTick(() => selectComponent(newText));
+  } else if (dragType.value === 'scrollingText') {
+    const newScrollingText = {
+      type: 'scrollingText',
+      text: '新滚动文本',
+      color: '#222',
+      fontSize: 24,
+      fontWeight: 'normal',
+      align: 'center',
+      speed: 50,
+      x: x,
+      y: y,
+      width: 300,
+      height: 40,
+      depth: getMaxDepth() + 1
+    };
+    editorContent.value.elements.push(newScrollingText);
+    nextTick(() => selectComponent(newScrollingText));
+  } else if (dragType.value === 'mediaAsset') {
+    const newMediaAsset = {
+      type: 'mediaAsset',
+      mediaId: '',
+      x: x,
+      y: y,
+      width: 120,
+      height: 120,
+      depth: getMaxDepth() + 1
+    };
+    editorContent.value.elements.push(newMediaAsset);
+    nextTick(() => selectComponent(newMediaAsset));
   }
-  dragComponentType.value = null;
+  dragType.value = null;
 }
 
 const containerSize = ref({ width: 0, height: 0 });
@@ -403,19 +504,19 @@ const goBack = () => {
 
 .toolbar {
   display: flex;
-  flex-direction: column;
+  flex-direction: row;
   align-self: flex-start;
-  align-items: flex-start;
-  justify-content: stretch;
-  width: 100px;
+  align-items: center;
   height: 50px;
+  width: 450px;
   margin-left: 5%;
   margin-top: -5%;
   margin-bottom: 1%;
   background: #fafbfc;
   border-radius: 8px;
   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
-  /* padding: 6px 0; */
+  gap: 16px;
+  padding: 0 16px;
 }
 
 .toolbar-item {

+ 124 - 0
smsb-plus-ui/src/views/smsb/itemProgram/component/MediaAssetBoard.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="media-asset-board-wrapper" :class="{ selected }" :style="{
+    width: props.width + 'px',
+    height: props.height + 'px',
+    position: 'relative',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center'
+  }">
+    <div class="media-asset-icon">
+      <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
+        <rect x="3" y="3" width="26" height="26" rx="6" fill="#f3f6fa" stroke="#409eff" stroke-width="2" />
+        <path d="M8 22l6-6 4 4 6-6" stroke="#409eff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
+        <circle cx="11" cy="12" r="2" fill="#409eff" />
+      </svg>
+    </div>
+    <template v-if="selected">
+      <div v-for="dir in ['nw', 'ne', 'sw', 'se']" :key="dir" class="resize-handle" :class="'resize-' + dir"
+        @mousedown.stop="onResizeMouseDown(dir, $event)" />
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, withDefaults, defineEmits } from 'vue';
+const emit = defineEmits(['resize']);
+interface Props {
+  width?: number;
+  height?: number;
+  mediaId?: string;
+  selected?: boolean;
+}
+const props = withDefaults(defineProps<Props>(), {
+  width: 120,
+  height: 120
+});
+const selected = computed(() => !!props.selected);
+
+let startX = 0,
+  startY = 0,
+  startW = 0,
+  startH = 0;
+function onResizeMouseDown(dir: string, e: MouseEvent) {
+  e.stopPropagation();
+  startX = e.clientX;
+  startY = e.clientY;
+  startW = props.width;
+  startH = props.height;
+  function onMouseMove(ev: MouseEvent) {
+    let dx = ev.clientX - startX;
+    let dy = ev.clientY - startY;
+    let newW = startW;
+    let newH = startH;
+    if (dir.includes('e')) newW = Math.max(40, startW + dx);
+    if (dir.includes('s')) newH = Math.max(40, startH + dy);
+    if (dir.includes('w')) newW = Math.max(40, startW - dx);
+    if (dir.includes('n')) newH = Math.max(40, startH - dy);
+    emit('resize', { width: newW, height: newH });
+  }
+  function onMouseUp() {
+    window.removeEventListener('mousemove', onMouseMove);
+    window.removeEventListener('mouseup', onMouseUp);
+  }
+  window.addEventListener('mousemove', onMouseMove);
+  window.addEventListener('mouseup', onMouseUp);
+}
+</script>
+
+<style scoped>
+.media-asset-board-wrapper {
+  background: #f9fbfd;
+  border: 2px dashed #b3c7e6;
+  border-radius: 10px;
+  transition: border-color 0.2s;
+  position: relative;
+}
+
+.media-asset-board-wrapper.selected {
+  border-color: #409eff;
+}
+
+.media-asset-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+}
+
+.resize-handle {
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  background: #fff;
+  border: 2px solid #409eff;
+  border-radius: 50%;
+  z-index: 10;
+  cursor: pointer;
+}
+
+.resize-nw {
+  left: -6px;
+  top: -6px;
+  cursor: nwse-resize;
+}
+
+.resize-ne {
+  right: -6px;
+  top: -6px;
+  cursor: nesw-resize;
+}
+
+.resize-sw {
+  left: -6px;
+  bottom: -6px;
+  cursor: nesw-resize;
+}
+
+.resize-se {
+  right: -6px;
+  bottom: -6px;
+  cursor: nwse-resize;
+}
+</style>

+ 175 - 0
smsb-plus-ui/src/views/smsb/itemProgram/component/ScrollingTextBoard.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="scrolling-text-board-wrapper" :class="{ selected }"
+    :style="{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden' }">
+    <div class="scrolling-text-board" :style="textStyle">
+      <div class="scrolling-text-content" :style="scrollStyle" ref="scrollRef">{{ text }}</div>
+    </div>
+    <template v-if="selected">
+      <div v-for="dir in ['tr', 'tl', 'br', 'bl']" :key="dir" class="resize-handle" :class="dir"
+        @mousedown.stop="onResizeMouseDown($event, dir)"></div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
+const emit = defineEmits(['resize']);
+interface Props {
+  text?: string;
+  color?: string;
+  fontSize?: string | number;
+  fontWeight?: string | number;
+  align?: 'left' | 'center' | 'right';
+  width?: number;
+  height?: number;
+  selected?: boolean;
+  speed?: number; // 滚动速度,px/秒
+}
+const props = defineProps<Props>();
+const selected = computed(() => !!props.selected);
+const text = computed(() => props.text || '双击编辑文本');
+const speed = computed(() => props.speed || 50);
+const scrollRef = ref<HTMLElement | null>(null);
+const textStyle = computed(() => ({
+  color: props.color || '#222',
+  fontSize: typeof props.fontSize === 'number' ? props.fontSize + 'px' : props.fontSize || '24px',
+  fontWeight: props.fontWeight || 'normal',
+  textAlign: props.align || 'center',
+  width: '100%',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center',
+  wordBreak: 'break-all',
+  overflow: 'hidden',
+  position: 'relative'
+}));
+const scrollStyle = ref<any>({ position: 'absolute', transform: 'translateX(0px)', whiteSpace: 'nowrap' });
+let reqId: number | null = null;
+let scrollLeft = 0;
+let textWidth = 0;
+let wrapperWidth = 0;
+function startScroll() {
+  if (!scrollRef.value) return;
+  // 获取最近的 .scrolling-text-board 作为容器宽度
+  let wrapper = scrollRef.value.parentElement;
+  while (wrapper && !wrapper.classList.contains('scrolling-text-board')) {
+    wrapper = wrapper.parentElement;
+  }
+  textWidth = scrollRef.value.offsetWidth;
+  wrapperWidth = wrapper ? wrapper.offsetWidth : 0;
+  scrollLeft = wrapperWidth;
+  scrollStyle.value.transform = `translateX(${scrollLeft}px)`;
+  cancelScroll();
+  loop();
+}
+function loop() {
+  scrollLeft -= speed.value / 60;
+  if (scrollLeft < -textWidth) {
+    scrollLeft = wrapperWidth;
+  }
+  scrollStyle.value.transform = `translateX(${scrollLeft}px)`;
+  reqId = requestAnimationFrame(loop);
+}
+function cancelScroll() {
+  if (reqId !== null) {
+    cancelAnimationFrame(reqId);
+    reqId = null;
+  }
+}
+onMounted(() => {
+  startScroll();
+});
+onBeforeUnmount(() => {
+  cancelScroll();
+});
+watch(
+  () => props.text,
+  () => startScroll()
+);
+watch(
+  () => props.speed,
+  () => startScroll()
+);
+function onResizeMouseDown(e: MouseEvent, dir: string) {
+  e.stopPropagation();
+  const startX = e.clientX,
+    startY = e.clientY;
+  const startWidth = Number(props.width) || 200;
+  const startHeight = Number(props.height) || 40;
+  function onMove(ev: MouseEvent) {
+    let newWidth = startWidth,
+      newHeight = startHeight;
+    if (dir.includes('r')) newWidth += ev.clientX - startX;
+    if (dir.includes('l')) newWidth -= ev.clientX - startX;
+    if (dir.includes('b')) newHeight += ev.clientY - startY;
+    if (dir.includes('t')) newHeight -= ev.clientY - startY;
+    emit('resize', { width: Math.max(20, newWidth), height: Math.max(20, newHeight) });
+  }
+  function onUp() {
+    document.removeEventListener('mousemove', onMove);
+    document.removeEventListener('mouseup', onUp);
+  }
+  document.addEventListener('mousemove', onMove);
+  document.addEventListener('mouseup', onUp);
+}
+</script>
+
+<style scoped>
+.scrolling-text-board-wrapper.selected {
+  outline: 2px dashed #409eff;
+  outline-offset: 0;
+}
+
+.scrolling-text-board {
+  width: 100%;
+  height: 100%;
+  background: transparent;
+  outline: none;
+  overflow: hidden;
+  position: relative;
+}
+
+.scrolling-text-content {
+  position: absolute;
+  left: 0;
+  transform: translateY(-50%);
+  white-space: nowrap;
+  will-change: left;
+  user-select: none;
+}
+
+.resize-handle {
+  width: 8px;
+  height: 8px;
+  background: #fff;
+  border: 1.5px solid #409eff;
+  border-radius: 50%;
+  position: absolute;
+  z-index: 2;
+}
+
+.resize-handle.tr {
+  top: -4px;
+  right: -4px;
+  cursor: ne-resize;
+}
+
+.resize-handle.tl {
+  top: -4px;
+  left: -4px;
+  cursor: nw-resize;
+}
+
+.resize-handle.br {
+  bottom: -4px;
+  right: -4px;
+  cursor: se-resize;
+}
+
+.resize-handle.bl {
+  bottom: -4px;
+  left: -4px;
+  cursor: sw-resize;
+}
+</style>

+ 74 - 4
smsb-plus-ui/src/views/smsb/itemProgram/component/TextBoard.vue

@@ -1,20 +1,29 @@
 <template>
-  <div class="text-board" :style="textStyle">
-    {{ text }}
+  <div class="text-board-wrapper" :class="{ selected }"
+    :style="{ width: '100%', height: '100%', position: 'relative' }">
+    <div class="text-board" :style="textStyle">{{ text }}</div>
+    <template v-if="selected">
+      <div v-for="dir in ['tr', 'tl', 'br', 'bl']" :key="dir" class="resize-handle" :class="dir"
+        @mousedown.stop="onResizeMouseDown($event, dir)"></div>
+    </template>
   </div>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue';
+const emit = defineEmits(['resize']);
 interface Props {
   text?: string;
   color?: string;
   fontSize?: string | number;
   fontWeight?: string | number;
   align?: 'left' | 'center' | 'right';
+  width?: number;
+  height?: number;
+  selected?: boolean;
 }
 const props = defineProps<Props>();
-
+const selected = computed(() => !!props.selected);
 const textStyle = computed(() => ({
   color: props.color || '#222',
   fontSize: typeof props.fontSize === 'number' ? props.fontSize + 'px' : props.fontSize || '24px',
@@ -25,13 +34,74 @@ const textStyle = computed(() => ({
   display: 'flex',
   alignItems: 'center',
   justifyContent: props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center',
-  wordBreak: 'break-all',
+  userSelect: 'none',
 }));
 
+function onResizeMouseDown(e: MouseEvent, dir: string) {
+  e.stopPropagation();
+  const startX = e.clientX,
+    startY = e.clientY;
+  const startWidth = Number(props.width) || 200;
+  const startHeight = Number(props.height) || 40;
+  function onMove(ev: MouseEvent) {
+    let newWidth = startWidth,
+      newHeight = startHeight;
+    if (dir.includes('r')) newWidth += ev.clientX - startX;
+    if (dir.includes('l')) newWidth -= ev.clientX - startX;
+    if (dir.includes('b')) newHeight += ev.clientY - startY;
+    if (dir.includes('t')) newHeight -= ev.clientY - startY;
+    emit('resize', { width: Math.max(20, newWidth), height: Math.max(20, newHeight) });
+  }
+  function onUp() {
+    document.removeEventListener('mousemove', onMove);
+    document.removeEventListener('mouseup', onUp);
+  }
+  document.addEventListener('mousemove', onMove);
+  document.addEventListener('mouseup', onUp);
+}
 const text = computed(() => props.text || '双击编辑文本');
 </script>
 
 <style scoped>
+.text-board-wrapper.selected {
+  outline: 2px dashed #409eff;
+  outline-offset: 0;
+}
+
+.resize-handle {
+  width: 8px;
+  height: 8px;
+  background: #fff;
+  border: 1.5px solid #409eff;
+  border-radius: 50%;
+  position: absolute;
+  z-index: 2;
+}
+
+.resize-handle.tr {
+  top: -4px;
+  right: -4px;
+  cursor: ne-resize;
+}
+
+.resize-handle.tl {
+  top: -4px;
+  left: -4px;
+  cursor: nw-resize;
+}
+
+.resize-handle.br {
+  bottom: -4px;
+  right: -4px;
+  cursor: se-resize;
+}
+
+.resize-handle.bl {
+  bottom: -4px;
+  left: -4px;
+  cursor: sw-resize;
+}
+
 .text-board {
   width: 100%;
   height: 100%;

+ 33 - 0
smsb-plus-ui/src/views/smsb/itemProgram/component/propNameMaps.ts

@@ -3,3 +3,36 @@ export const canvasPropNameMap = {
   height: '高度',
   bg: '背景'
 };
+
+export const textPropNameMap = {
+  text: '内容',
+  color: '颜色',
+  fontSize: '字号',
+  fontWeight: '字重',
+  align: '对齐方式',
+  x: '横坐标',
+  y: '纵坐标',
+  width: '宽度',
+  height: '高度'
+};
+
+export const scrollingTextPropNameMap = {
+  text: '内容',
+  color: '颜色',
+  fontSize: '字号',
+  fontWeight: '字重',
+  align: '对齐方式',
+  speed: '滚动速度',
+  x: '横坐标',
+  y: '纵坐标',
+  width: '宽度',
+  height: '高度'
+};
+
+export const mediaAssetPropNameMap = {
+  mediaId: '媒资ID',
+  width: '宽度',
+  height: '高度',
+  x: '横坐标',
+  y: '纵坐标'
+};