ソースを参照

支持直播组件

Shinohara Haruna 5 ヶ月 前
コミット
0642d727d2

+ 38 - 13
smsb-plus-ui/src/views/smsb/itemProgram/EditProgram.vue

@@ -21,6 +21,7 @@
             <template v-else-if="item.type === 'text'">文本</template>
             <template v-else-if="item.type === 'scrollingText'">滚动文本</template>
             <template v-else-if="item.type === 'mediaAsset'">媒资</template>
+            <template v-else-if="item.type === 'live'">直播</template>
             <!-- 未来可扩展图片等类型 -->
           </div>
         </div>
@@ -33,6 +34,7 @@
         <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 class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('live')">直播</div>
         <!-- 可扩展更多组件 -->
       </div>
       <div class="editor-canvas" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
@@ -73,6 +75,13 @@
                   item.height = height;
                 }
               " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+            <LiveBoard v-if="item.type === 'live'" :width="item.width" :height="item.height" :live-url="item.liveUrl"
+              :play-audio="item.playAudio" :selected="selectedComponent === item" @resize="
+                ({ width, height }) => {
+                  item.width = width;
+                  item.height = height;
+                }
+              " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
             <!-- 未来可扩展更多类型 -->
           </div>
         </template>
@@ -93,7 +102,12 @@
             <div style="margin-bottom: 8px; font-weight: bold">组件属性</div>
             <template v-for="[key, value] in Object.entries(selectedComponent || {})" :key="key">
               <el-form-item v-if="showEditableProp(key)" :label="getPropLabel(key)">
-                <el-input v-model="selectedComponent[key]" />
+                <template v-if="selectedComponent.type === 'live' && key === 'playAudio'">
+                  <el-switch v-model="selectedComponent[key]" active-text="开" inactive-text="关" />
+                </template>
+                <template v-else>
+                  <el-input v-model="selectedComponent[key]" />
+                </template>
               </el-form-item>
             </template>
           </template>
@@ -149,7 +163,8 @@ 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';
+import LiveBoard from './component/LiveBoard.vue';
+import { canvasPropNameMap, textPropNameMap, scrollingTextPropNameMap, mediaAssetPropNameMap, livePropNameMap } from './component/propNameMaps';
 // 拖拽类型
 const dragType = ref<string | null>(null);
 // 获取最大 depth
@@ -187,19 +202,17 @@ 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') {
+  } else if (selectedComponent.value?.type === 'text') {
     return textPropNameMap[key] || key;
-  }
-  if (selectedComponent.value?.type === 'scrollingText') {
+  } else if (selectedComponent.value?.type === 'scrollingText') {
     return scrollingTextPropNameMap[key] || key;
-  }
-  if (selectedComponent.value?.type === 'mediaAsset') {
+  } else if (selectedComponent.value?.type === 'mediaAsset') {
     return mediaAssetPropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'live') {
+    return livePropNameMap[key] || key;
   }
   return key;
 }
@@ -307,9 +320,8 @@ function onElementMouseMove(e: MouseEvent) {
   if (draggingId.value === null) return;
   const item = editorContent.value.elements.find((el) => el.depth === draggingId.value);
   if (!item) return;
-  const scale = canvasScale.value || 1;
-  item.x = dragStart.offsetX + (e.clientX - dragStart.x) / scale;
-  item.y = dragStart.offsetY + (e.clientY - dragStart.y) / scale;
+  item.x = dragStart.offsetX + (e.clientX - dragStart.x);
+  item.y = dragStart.offsetY + (e.clientY - dragStart.y);
 }
 
 function onElementMouseUp() {
@@ -372,6 +384,19 @@ function onCanvasDrop(e: DragEvent) {
     };
     editorContent.value.elements.push(newMediaAsset);
     nextTick(() => selectComponent(newMediaAsset));
+  } else if (dragType.value === 'live') {
+    const newLive = {
+      type: 'live',
+      liveUrl: '',
+      playAudio: true,
+      x: x,
+      y: y,
+      width: 200,
+      height: 120,
+      depth: getMaxDepth() + 1
+    };
+    editorContent.value.elements.push(newLive);
+    nextTick(() => selectComponent(newLive));
   }
   dragType.value = null;
 }
@@ -508,7 +533,7 @@ const goBack = () => {
   align-self: flex-start;
   align-items: center;
   height: 50px;
-  width: 450px;
+  width: 700px;
   margin-left: 5%;
   margin-top: -5%;
   margin-bottom: 1%;

+ 267 - 0
smsb-plus-ui/src/views/smsb/itemProgram/component/LiveBoard.vue

@@ -0,0 +1,267 @@
+<template>
+  <div class="live-board-wrapper" :class="{ selected }" :style="{
+    width: width + 'px',
+    height: height + 'px',
+    position: 'relative',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    background: '#f8fafc',
+    boxSizing: 'border-box',
+    border: selected ? '2px solid #409eff' : '1px solid #ddd'
+  }">
+    <div class="live-board-content">
+      <div class="live-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="#e67e22" stroke-width="2" />
+          <path d="M12 11v10l8-5-8-5z" fill="#e67e22" />
+        </svg>
+      </div>
+      <div class="live-info" v-if="liveUrl || selected">
+        <div class="live-url" :title="liveUrl"><span class="label">直播地址:</span>{{ liveUrl || '未设置' }}</div>
+        <div class="live-audio"><span class="label">音频:</span>{{ playAudio ? '开' : '关' }}</div>
+      </div>
+    </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;
+  liveUrl?: string;
+  playAudio?: boolean;
+  selected?: boolean;
+}
+const props = withDefaults(defineProps<Props>(), {
+  width: 200,
+  height: 120,
+  liveUrl: '',
+  playAudio: true,
+  selected: false
+});
+const selected = computed(() => !!props.selected);
+const width = computed(() => props.width);
+const height = computed(() => props.height);
+const liveUrl = computed(() => props.liveUrl);
+const playAudio = computed(() => props.playAudio);
+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);
+}
+function select() {
+  // 交由父组件处理选中
+}
+</script>
+
+<style scoped>
+.live-board-wrapper {
+  user-select: none;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  background: #f8fafc;
+  border-radius: 10px;
+  transition: border-color 0.2s;
+  position: relative;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.live-board-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+}
+
+.live-icon {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.live-info {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+  gap: 2px;
+  font-size: 15px;
+  color: #222;
+}
+
+.live-url,
+.live-audio {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 180px;
+}
+
+.label {
+  color: #e67e22;
+  font-weight: bold;
+  margin-right: 4px;
+}
+
+.live-board-wrapper.selected {
+  border-color: #409eff;
+}
+
+.live-icon {
+  margin-right: 10px;
+}
+
+.live-info {
+  font-size: 13px;
+  color: #e67e22;
+  margin-top: 4px;
+  text-align: center;
+}
+
+.live-url {
+  font-size: 13px;
+  color: #e67e22;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 120px;
+}
+
+.live-audio {
+  font-size: 12px;
+  color: #888;
+  margin-top: 4px;
+}
+
+.resize-handle {
+  width: 8px;
+  height: 8px;
+  background: #fff;
+  border: 1.5px solid #e67e22;
+  border-radius: 50%;
+  position: absolute;
+  z-index: 2;
+}
+
+.resize-nw {
+  top: -4px;
+  left: -4px;
+  cursor: nw-resize;
+}
+
+.resize-ne {
+  top: -4px;
+  right: -4px;
+  cursor: ne-resize;
+}
+
+.resize-sw {
+  bottom: -4px;
+  left: -4px;
+  cursor: sw-resize;
+}
+
+.resize-se {
+  bottom: -4px;
+  right: -4px;
+  cursor: se-resize;
+}
+
+.live-icon {
+  margin-right: 10px;
+}
+
+.live-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+}
+
+.live-url {
+  font-size: 13px;
+  color: #e67e22;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 120px;
+}
+
+.live-audio {
+  font-size: 12px;
+  color: #888;
+  margin-top: 4px;
+}
+
+.resize-handle {
+  width: 8px;
+  height: 8px;
+  background: #fff;
+  border: 1.5px solid #e67e22;
+  border-radius: 50%;
+  position: absolute;
+  z-index: 2;
+}
+
+.resize-nw {
+  top: -4px;
+  left: -4px;
+  cursor: nw-resize;
+}
+
+.resize-ne {
+  top: -4px;
+  right: -4px;
+  cursor: ne-resize;
+}
+
+.resize-sw {
+  bottom: -4px;
+  left: -4px;
+  cursor: sw-resize;
+}
+
+.resize-se {
+  bottom: -4px;
+  right: -4px;
+  cursor: se-resize;
+}
+</style>

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

@@ -36,3 +36,12 @@ export const mediaAssetPropNameMap = {
   x: '横坐标',
   y: '纵坐标'
 };
+
+export const livePropNameMap = {
+  liveUrl: '直播地址',
+  playAudio: '播放音频',
+  width: '宽度',
+  height: '高度',
+  x: '横坐标',
+  y: '纵坐标'
+};