Sfoglia il codice sorgente

style:分屏组可视化拖拽编辑页面UI美化

lihao16 2 mesi fa
parent
commit
b4636a89fb

+ 464 - 640
smsb-plus-ui/src/views/smsb/itemProgram/EditProgram.vue

@@ -1,148 +1,175 @@
 <template>
   <div class="edit-program-layout">
-    <!-- 左侧组件栏及返回按钮 -->
     <div class="sidebar">
-      <el-button class="back-btn" type="default" @click="goBack" circle>
-        <el-icon>
-          <ArrowLeft />
-        </el-icon>
-      </el-button>
-      <div class="sidebar-title">组件</div>
-      <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)" draggable="true"
-          @dragstart="onSidebarDragStart(item, idx, $event)" @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>
-            <template v-else-if="item.type === 'live'">直播</template>
-            <template v-else-if="item.type === 'webPage'">网页</template>
-            <template v-else-if="item.type === 'clock'">时钟</template>
-            <!-- 未来可扩展图片等类型 -->
+      <div class="sidebar-header">
+        <el-button class="back-btn" type="default" @click="goBack" circle>
+          <el-icon><ArrowLeft /></el-icon>
+        </el-button>
+        <span class="sidebar-title">组件图层</span>
+      </div>
+
+      <div class="sidebar-content">
+        <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"
+            :class="{ 'selected': selectedComponent === item || (item.type === 'canvas' && selectedComponent && selectedComponent.type === 'canvas') }"
+            @click="item.type === 'canvas' ? selectCanvasFromSidebar() : selectComponent(item)"
+            draggable="true"
+            @dragstart="onSidebarDragStart(item, idx, $event)"
+            @dragover.prevent="onSidebarDragOver(item, idx, $event)"
+            @drop.prevent="onSidebarDrop(item, idx, $event)"
+          >
+            <div class="component-icon-text">
+              <el-icon :size="16" style="margin-right: 8px;">
+                <Picture v-if="item.type === 'canvas'" />
+                <Document v-else-if="item.type === 'text'" />
+                <DataLine v-else-if="item.type === 'scrollingText'" />
+                <Film v-else-if="item.type === 'mediaAsset'" />
+                <VideoCamera v-else-if="item.type === 'live'" />
+                <Link v-else-if="item.type === 'webPage'" />
+                <Clock v-else-if="item.type === 'clock'" />
+              </el-icon>
+              <span>
+                <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>
+                <template v-else-if="item.type === 'live'">直播</template>
+                <template v-else-if="item.type === 'webPage'">网页</template>
+                <template v-else-if="item.type === 'clock'">时钟</template>
+              </span>
+            </div>
           </div>
-        </div>
-      </template>
-      <div class="sidebar-recycle-bin" @dragover.prevent @drop="onRecycleDrop($event)" title="拖动组件到此处删除">
-        <el-icon style="font-size: 28px; color: #bbb">
-          <svg viewBox="0 0 1024 1024" width="1em" height="1em">
-            <path
-              d="M320 896c0 35.2 28.8 64 64 64h256c35.2 0 64-28.8 64-64V320H320v576z m352-704V160c0-35.2-28.8-64-64-64H416c-35.2 0-64 28.8-64 64v32H160v64h704v-64H672z m-64 0H416V160h192v32z"
-              fill="currentColor"></path>
-          </svg>
-        </el-icon>
-        <div style="font-size: 12px; color: #bbb">拖拽移除组件</div>
+        </template>
+      </div>
+
+      <div class="sidebar-recycle-bin" @dragover.prevent="onRecycleDrop($event)" title="拖动组件到此处删除">
+        <el-icon><Delete /></el-icon>
+        <span>拖拽到此移除</span>
       </div>
     </div>
 
-    <!-- 中间编辑区 -->
     <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 class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('live')">直播</div>
-        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('webPage')">网页</div>
-        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('clock')">时钟</div>
-        <!-- 可扩展更多组件 -->
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('text')">
+          <el-icon><Document /></el-icon><span>文本</span>
+        </div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('scrollingText')">
+          <el-icon><DataLine /></el-icon><span>滚动文本</span>
+        </div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('mediaAsset')">
+          <el-icon><Film /></el-icon><span>媒资</span>
+        </div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('live')">
+          <el-icon><VideoCamera /></el-icon><span>直播</span>
+        </div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('webPage')">
+          <el-icon><Link /></el-icon><span>网页</span>
+        </div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('clock')">
+          <el-icon><Clock /></el-icon><span>时钟</span>
+        </div>
       </div>
-      <div class="editor-canvas" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
-        <CanvasBoard v-if="canvasItem" :width="canvasItem.width" :height="canvasItem.height" :bg="canvasItem.bg"
-          :scale="canvasScale" @click.stop="selectComponent(canvasItem)"
-          :class="{ selected: selectedComponent === canvasItem }">
-          <template #default>
-            <template
-              v-for="item in editorContent.elements.filter((el) => el.type !== 'canvas').sort((a, b) => a.depth - b.depth)"
-              :key="item.depth + '-' + item.type">
-              <div :style="{
-                position: 'absolute',
-                left: (item.x || 0) * canvasScale + 'px',
-                top: (item.y || 0) * canvasScale + 'px',
-                width: (item.width || 200) * canvasScale + 'px',
-                height: (item.height || 40) * canvasScale + 'px',
-                cursor: draggingId === item.depth ? 'grabbing' : 'move',
-                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" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :selected="selectedComponent === item" @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @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 * canvasScale" :height="item.height * canvasScale" :speed="item.speed"
-                  :selected="selectedComponent === item" @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
-                <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :media-id="item.mediaId" :selected="selectedComponent === item"
-                  v-model="item.mediaGroup" :border-radius="item.borderRadius || 0" @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
-                <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio"
-                  :selected="selectedComponent === item" :border-radius="item.borderRadius || 0" @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
-                <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item"
-                  :border-radius="item.borderRadius || 0" @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
-                <ClockBoard v-if="item.type === 'clock'" :width="item.width * canvasScale"
-                  :height="item.height * canvasScale" :format="item.format" :selected="selectedComponent === item"
-                  @resize="
-                    ({ width, height }) => {
-                      item.width = width / canvasScale;
-                      item.height = height / canvasScale;
-                    }
-                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
-                <!-- 未来可扩展更多类型 -->
-              </div>
+      <div class="editor-canvas-wrapper" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
+        <div class="editor-canvas">
+          <CanvasBoard v-if="canvasItem" :width="canvasItem.width" :height="canvasItem.height" :bg="canvasItem.bg"
+                       :scale="canvasScale" @click.stop="selectComponent(canvasItem)"
+                       :class="{ selected: selectedComponent === canvasItem }">
+            <template #default>
+              <template
+                v-for="item in editorContent.elements.filter((el) => el.type !== 'canvas').sort((a, b) => a.depth - b.depth)"
+                :key="item.depth + '-' + item.type">
+                <div :style="{
+                    position: 'absolute',
+                    left: (item.x || 0) * canvasScale + 'px',
+                    top: (item.y || 0) * canvasScale + 'px',
+                    width: (item.width || 200) * canvasScale + 'px',
+                    height: (item.height || 40) * canvasScale + 'px',
+                    cursor: draggingId === item.depth ? 'grabbing' : 'move',
+                    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" :width="item.width * canvasScale"
+                             :height="item.height * canvasScale" :selected="selectedComponent === item" @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @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 * canvasScale" :height="item.height * canvasScale" :speed="item.speed"
+                                      :selected="selectedComponent === item" @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                  <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
+                                   :height="item.height * canvasScale" :media-id="item.mediaId" :selected="selectedComponent === item"
+                                   v-model="item.mediaGroup" :border-radius="item.borderRadius || 0" @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                  <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
+                             :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio"
+                             :selected="selectedComponent === item" :border-radius="item.borderRadius || 0" @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                  <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
+                                :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item"
+                                :border-radius="item.borderRadius || 0" @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                  <ClockBoard v-if="item.type === 'clock'" :width="item.width * canvasScale"
+                              :height="item.height * canvasScale" :format="item.format" :selected="selectedComponent === item"
+                              @resize="
+                        ({ width, height }) => {
+                          item.width = width / canvasScale;
+                          item.height = height / canvasScale;
+                        }
+                      " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                </div>
+              </template>
             </template>
-          </template>
-        </CanvasBoard>
+          </CanvasBoard>
+        </div>
+      </div>
+      <div class="bottom-action-bar">
+        <el-button type="primary" class="save-btn" :loading="saveLoading" :disabled="saveLoading" @click="handleSave">
+          <el-icon style="margin-right: 6px;"><Finished /></el-icon>
+          保 存
+        </el-button>
       </div>
-      <el-button type="primary" class="save-btn" :loading="saveLoading" :disabled="saveLoading"
-        @click="handleSave">保存</el-button>
     </div>
 
-    <!-- 右侧属性栏 -->
     <div class="property-panel">
-      <div class="property-title">属性</div>
-      <div class="property-form-area">
-        <div class="property-info">
+      <div class="panel-header">
+        <span class="panel-title">属性配置</span>
+      </div>
+      <div class="panel-content">
+        <div class="property-info-card">
           <div class="property-info-row">
-            <span class="property-info-label">节目名称:</span>
-            <span>{{ programName }}</span>
+            <span class="property-info-label">节目名称</span>
+            <span class="property-info-value">{{ programName }}</span>
           </div>
           <div class="property-info-row">
-            <span class="property-info-label">分辨率</span>
-            <span>{{ programResolution }}</span>
+            <span class="property-info-label">分辨率</span>
+            <span class="property-info-value">{{ programResolution }}</span>
           </div>
         </div>
-        <!-- 动态显示选中组件的可编辑属性 -->
+
         <template v-if="selectedComponent">
-          <div style="margin-bottom: 8px; font-weight: bold">组件属性</div>
+          <el-divider content-position="left" style="margin: 24px 0;">组件属性</el-divider>
           <template v-for="[key, value] in Object.entries(selectedComponent || {})" :key="key">
             <el-form-item v-if="showEditableProp(key)" :label="getPropLabel(key)">
               <template v-if="selectedComponent.type === 'live' && key === 'playAudio'">
@@ -173,119 +200,123 @@
               </template>
             </el-form-item>
           </template>
-          <!-- 对齐尺寸操作区 -->
-          <div style="margin: 12px 0">
-            <div style="font-weight: bold; margin-bottom: 10px">对齐尺寸</div>
-            <!-- 第一组 2x2 -->
-            <div
-              style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
-              <el-button size="small" style="width: 100%" @click="alignComponent('left')">水平靠左</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('right')">水平靠右</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('top')">垂直靠上</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('bottom')">垂直靠下</el-button>
+
+          <div v-if="selectedComponent.type !== 'canvas'">
+            <el-divider content-position="left" style="margin: 24px 0;">快速调整</el-divider>
+            <div class="align-tool-group">
+              <div class="align-tool-title">对齐</div>
+              <div class="align-buttons">
+                <el-button @click="alignComponent('left')" title="左对齐"><el-icon><Upload /></el-icon></el-button>
+                <el-button @click="alignComponent('top')" title="上对齐"><el-icon><Upload /></el-icon></el-button>
+                <el-button @click="alignComponent('right')" title="右对齐"><el-icon><Upload /></el-icon></el-button>
+                <el-button @click="alignComponent('bottom')" title="下对齐"><el-icon><Upload /></el-icon></el-button>
+              </div>
             </div>
-            <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
-            <!-- 第二组 2x2 -->
-            <div
-              style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
-              <el-button size="small" style="width: 100%" @click="alignComponent('width-full')">宽铺满</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('width-half')">宽半屏</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('width-third')">宽1/3屏</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('width-quarter')">宽1/4屏</el-button>
+            <div class="align-tool-group">
+              <div class="align-tool-title">宽度</div>
+              <el-button-group>
+                <el-button @click="alignComponent('width-full')">铺满</el-button>
+                <el-button @click="alignComponent('width-half')">1/2</el-button>
+                <el-button @click="alignComponent('width-third')">1/3</el-button>
+                <el-button @click="alignComponent('width-quarter')">1/4</el-button>
+              </el-button-group>
             </div>
-            <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
-            <!-- 第三组 2x2 -->
-            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 4px; padding-left: 2px">
-              <el-button size="small" style="width: 100%" @click="alignComponent('height-full')">高铺满</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('height-half')">高半屏</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('height-third')">高1/3屏</el-button>
-              <el-button size="small" style="width: 100%" @click="alignComponent('height-quarter')">高1/4屏</el-button>
+            <div class="align-tool-group">
+              <div class="align-tool-title">高度</div>
+              <el-button-group>
+                <el-button @click="alignComponent('height-full')">铺满</el-button>
+                <el-button @click="alignComponent('height-half')">1/2</el-button>
+                <el-button @click="alignComponent('height-third')">1/3</el-button>
+                <el-button @click="alignComponent('height-quarter')">1/4</el-button>
+              </el-button-group>
             </div>
           </div>
         </template>
         <template v-else>
-          <div style="color: #bbb">请点击编辑区中的组件以编辑属性</div>
+          <el-empty description="请在画布中选择一个组件" :image-size="80" />
+        </template>
+
+        <template v-if="isLocalDev">
+          <el-divider content-position="left" style="margin: 24px 0;">JSON 数据</el-divider>
+          <el-input class="json-debug" type="textarea" :rows="8" :model-value="JSON.stringify(editorContent, null, 2)"
+                    readonly />
         </template>
       </div>
-      <hr class="property-divider" />
-      <template v-if="isLocalDev">
-        <div class="json-debug-title">当前JSON</div>
-        <el-input class="json-debug" type="textarea" :rows="8" :model-value="JSON.stringify(editorContent, null, 2)"
-          readonly />
-      </template>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted, computed, nextTick } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import {
+  ArrowLeft,
+  Picture,
+  Document,
+  DataLine,
+  Film,
+  VideoCamera,
+  Link,
+  Clock,
+  Delete,
+  Finished,
+  Upload,
+} from '@element-plus/icons-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 CarouselGroupSelector from '@/components/CarouselGroupSelector.vue';
+import BackgroundSelector from '@/components/BackgroundSelector.vue';
+import LiveBoard from './component/LiveBoard.vue';
+import WebPageBoard from './component/WebPageBoard.vue';
+import ClockBoard from './component/ClockBoard.vue';
+import {
+  canvasPropNameMap,
+  textPropNameMap,
+  scrollingTextPropNameMap,
+  mediaAssetPropNameMap,
+  livePropNameMap,
+  webPagePropNameMap,
+  clockPropNameMap
+} from './component/propNameMaps';
+import { getItemProgram, updateItemProgram } from '@/api/smsb/source/item_program';
+
 // 本地开发环境开关,正式环境请设为 false 或用 import.meta.env 读取
 const isLocalDev = false;
+
 // 对齐尺寸操作
 function alignComponent(type: string) {
   if (!selectedComponent.value || selectedComponent.value.type === 'canvas') return;
-  // 找到canvas尺寸
   const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
   if (!canvas) return;
   const cW = Number(canvas.width) || 600;
   const cH = Number(canvas.height) || 400;
-  // 只操作当前选中组件
   const comp = selectedComponent.value;
   switch (type) {
-    case 'left':
-      comp.x = 0;
-      break;
-    case 'right':
-      comp.x = cW - (Number(comp.width) || 0);
-      break;
-    case 'top':
-      comp.y = 0;
-      break;
-    case 'bottom':
-      comp.y = cH - (Number(comp.height) || 0);
-      break;
-    case 'width-full':
-      comp.x = 0;
-      comp.width = cW;
-      break;
-    case 'width-half':
-      comp.x = 0;
-      comp.width = Math.round(cW / 2);
-      break;
-    case 'width-third':
-      comp.x = 0;
-      comp.width = Math.round(cW / 3);
-      break;
-    case 'width-quarter':
-      comp.x = 0;
-      comp.width = Math.round(cW / 4);
-      break;
-    case 'height-full':
-      comp.y = 0;
-      comp.height = cH;
-      break;
-    case 'height-half':
-      comp.y = 0;
-      comp.height = Math.round(cH / 2);
-      break;
-    case 'height-third':
-      comp.y = 0;
-      comp.height = Math.round(cH / 3);
-      break;
-    case 'height-quarter':
-      comp.y = 0;
-      comp.height = Math.round(cH / 4);
-      break;
+    case 'left': comp.x = 0; break;
+    case 'right': comp.x = cW - (Number(comp.width) || 0); break;
+    case 'top': comp.y = 0; break;
+    case 'bottom': comp.y = cH - (Number(comp.height) || 0); break;
+    case 'width-full': comp.x = 0; comp.width = cW; break;
+    case 'width-half': comp.x = 0; comp.width = Math.round(cW / 2); break;
+    case 'width-third': comp.x = 0; comp.width = Math.round(cW / 3); break;
+    case 'width-quarter': comp.x = 0; comp.width = Math.round(cW / 4); break;
+    case 'height-full': comp.y = 0; comp.height = cH; break;
+    case 'height-half': comp.y = 0; comp.height = Math.round(cH / 2); break;
+    case 'height-third': comp.y = 0; comp.height = Math.round(cH / 3); break;
+    case 'height-quarter': comp.y = 0; comp.height = Math.round(cH / 4); break;
   }
 }
 
 // 拖拽排序相关
 const sidebarDrag = ref<{ item: any; idx: number } | null>(null);
 
-// 回收站拖拽释放事件,移除对应组件
 function onRecycleDrop(e: DragEvent) {
   const depth = e.dataTransfer?.getData('component-depth');
   if (!depth) return;
-  // 不能删除画布类型
   const idx = editorContent.value.elements.findIndex((el) => String(el.depth) === depth && el.type !== 'canvas');
   if (idx > -1) {
     editorContent.value.elements.splice(idx, 1);
@@ -293,7 +324,6 @@ function onRecycleDrop(e: DragEvent) {
   }
 }
 
-// 拖拽开始时,将组件depth放入dataTransfer
 function onSidebarDragStart(item: any, idx: number, e?: DragEvent) {
   sidebarDrag.value = { item, idx };
   if (e && e.dataTransfer) {
@@ -306,7 +336,6 @@ function onSidebarDragOver(targetItem: any, targetIdx: number, e: DragEvent) {
 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);
@@ -314,12 +343,10 @@ function onSidebarDrop(targetItem: any, targetIdx: number, e: DragEvent) {
     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;
@@ -327,48 +354,21 @@ function onSidebarDrop(targetItem: any, targetIdx: number, e: DragEvent) {
   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 CarouselGroupSelector from '@/components/CarouselGroupSelector.vue';
-import BackgroundSelector from '@/components/BackgroundSelector.vue';
-import LiveBoard from './component/LiveBoard.vue';
-import WebPageBoard from './component/WebPageBoard.vue';
-import ClockBoard from './component/ClockBoard.vue';
-import {
-  canvasPropNameMap,
-  textPropNameMap,
-  scrollingTextPropNameMap,
-  mediaAssetPropNameMap,
-  livePropNameMap,
-  webPagePropNameMap,
-  clockPropNameMap
-} 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';
-import { getItemProgram, updateItemProgram } from '@/api/smsb/source/item_program';
 
 const route = useRoute();
+const router = useRouter();
 
-// 当前选中组件
 const selectedComponent = ref<any>(null);
 
-// 选中组件方法
 function selectComponent(item: any) {
   selectedComponent.value = item;
 }
 
-// 左侧栏点击选中画布
 function selectCanvasFromSidebar() {
   const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
   if (canvas) {
@@ -376,18 +376,14 @@ function selectCanvasFromSidebar() {
   }
 }
 
-// 属性栏显示哪些属性可编辑(可根据实际需求过滤)
 function showEditableProp(key: string) {
-  // 明确排除 type 字段,防止被编辑
   if (!selectedComponent.value) return false;
-  // 对于文本、滚动文本和时钟组件,移除 'fontWeight' 和 'align' 属性
   if (
     (selectedComponent.value.type === 'text' || selectedComponent.value.type === 'scrollingText' || selectedComponent.value.type === 'clock') &&
     (key === 'fontWeight' || key === 'align')
   ) {
     return false;
   }
-  // 画布、文本、滚动文本和时钟组件不显示 borderRadius 属性
   if (
     (selectedComponent.value.type === 'canvas' ||
       selectedComponent.value.type === 'text' ||
@@ -400,97 +396,42 @@ function showEditableProp(key: string) {
   return !['type', 'depth'].includes(key);
 }
 
-// 获取属性中文名
 function getPropLabel(key: string) {
-  if (selectedComponent.value?.type === 'canvas') {
-    return canvasPropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'text') {
-    return textPropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'scrollingText') {
-    return scrollingTextPropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'mediaAsset') {
-    return mediaAssetPropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'live') {
-    return livePropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'webPage') {
-    return webPagePropNameMap[key] || key;
-  } else if (selectedComponent.value?.type === 'clock') {
-    return clockPropNameMap[key] || key;
-  }
-  return key;
+  const maps = {
+    canvas: canvasPropNameMap,
+    text: textPropNameMap,
+    scrollingText: scrollingTextPropNameMap,
+    mediaAsset: mediaAssetPropNameMap,
+    live: livePropNameMap,
+    webPage: webPagePropNameMap,
+    clock: clockPropNameMap
+  };
+  const currentMap = maps[selectedComponent.value?.type];
+  return currentMap?.[key] || key;
 }
 
-const router = useRouter();
-// 自动修正 id 类型,确保为 string 或 number
-const rawId = route.params.id;
-const id = ref<string | number>(Array.isArray(rawId) ? rawId[0] : rawId);
-
-// 自动填充画布分辨率
-onMounted(async () => {
-  try {
-    const res = await getItemProgram(id.value);
-    const data = res.data;
-    let resolutionRatio = '';
-    if (data && data.resolutionRatio) {
-      resolutionRatio = data.resolutionRatio;
-    }
-    // 优先使用后端返回的 itemJsonStr 字段
-    let parsed = { elements: [] };
-    if (data && data.itemJsonStr) {
-      try {
-        parsed = JSON.parse(data.itemJsonStr);
-      } catch (err) {
-        // 解析失败则回退到空布局
-        parsed = { elements: [] };
-      }
-    }
-    editorContent.value = parsed;
-    ensureCanvasAndDepth(editorContent.value.elements, resolutionRatio);
-  } catch (e) {
-    // fallback: 初始化 elements 并插入默认画布
-    editorContent.value = { elements: [] };
-    ensureCanvasAndDepth(editorContent.value.elements);
-  }
-});
+const id = ref<string | number>(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id);
 
 function ensureCanvasAndDepth(elements, resolutionRatio?: string) {
-  let width = 600,
-    height = 400;
+  let width = 600, height = 400;
   if (resolutionRatio) {
     const [w, h] = resolutionRatio.split('x').map(Number);
     if (w && h) {
       width = w;
       height = h;
-      // console.log('#136: ', width, height);
     }
   }
-  // 检查是否有 type: 'canvas' 的组件
   let idx = elements.findIndex((el) => el.type === 'canvas');
   if (idx === -1) {
-    // console.log('#141: ', width, height);
     elements.unshift({ type: 'canvas', width, height, bg: '#fff', depth: 0 });
   } else {
     let canvas = elements[idx];
-    let changed = false;
-    if (!canvas.width) {
-      canvas = { ...canvas, width };
-      changed = true;
-    }
-    if (!canvas.height) {
-      canvas = { ...canvas, height };
-      changed = true;
-    }
-    if (changed) {
-      elements[idx] = canvas; // 替换整个对象,确保响应式
-      // console.log('#145: ', width, height, '响应式canvas:', canvas);
-    } else {
-      // console.log('#145: ', width, height);
-    }
+    if (!canvas.width) canvas.width = width;
+    if (!canvas.height) canvas.height = height;
   }
-  // 按 depth 排序,如果没有 depth 则补齐
-  elements.forEach((el, idx) => {
+  elements.forEach((el, i) => {
     if (typeof el.depth !== 'number') {
-      el.depth = el.type === 'canvas' ? 0 : idx + 1;
+      el.depth = el.type === 'canvas' ? 0 : i + 1;
     }
   });
   elements.sort((a, b) => a.depth - b.depth);
@@ -505,7 +446,6 @@ interface EditorContent {
 }
 const editorContent = ref<EditorContent>({ elements: [] });
 
-// editor-canvas 缩放逻辑
 const editorCanvasRef = ref<HTMLElement | null>(null);
 const containerSize = ref({ width: 0, height: 0 });
 
@@ -529,7 +469,6 @@ function onElementMouseMove(e: MouseEvent) {
   if (draggingId.value === null) return;
   const item = editorContent.value.elements.find((el) => el.depth === draggingId.value);
   if (!item) return;
-  // 拖拽时坐标除以缩放比例,保证拖拽速度和鼠标一致
   item.x = dragStart.offsetX + (e.clientX - dragStart.x) / canvasScale.value;
   item.y = dragStart.offsetY + (e.clientY - dragStart.y) / canvasScale.value;
 }
@@ -549,94 +488,25 @@ function onCanvasDrop(e: DragEvent) {
   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: x,
-      y: y,
-      width: 200,
-      height: 40,
-      depth: getMaxDepth() + 1,
-      borderRadius: 0
-    };
-    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,
-      borderRadius: 0
-    };
-    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,
-      borderRadius: 0
-    };
-    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,
-      borderRadius: 0
-    };
-    editorContent.value.elements.push(newLive);
-    nextTick(() => selectComponent(newLive));
-  } else if (dragType.value === 'webPage') {
-    const newWebPage = {
-      type: 'webPage',
-      url: 'https://example.com',
-      x: x,
-      y: y,
-      width: 300,
-      height: 200,
-      depth: getMaxDepth() + 1,
-      borderRadius: 0
-    };
-    editorContent.value.elements.push(newWebPage);
-    nextTick(() => selectComponent(newWebPage));
-  } else if (dragType.value === 'clock') {
-    const newClock = {
-      type: 'clock',
-      format: '24h', // default format, can be '24h', '12h', or 'dateTime'
-      x: x,
-      y: y,
-      width: 200,
-      height: 60,
-      depth: getMaxDepth() + 1
-    };
-    editorContent.value.elements.push(newClock);
-    nextTick(() => selectComponent(newClock));
-  }
+
+  const componentDefaults = {
+    text: { type: 'text', text: '新文本', color: '#222', fontSize: 24, fontWeight: 'normal', align: 'center', width: 200, height: 40, borderRadius: 0 },
+    scrollingText: { type: 'scrollingText', text: '新滚动文本', color: '#222', fontSize: 24, fontWeight: 'normal', align: 'center', speed: 50, width: 300, height: 40, borderRadius: 0 },
+    mediaAsset: { type: 'mediaAsset', mediaId: '', width: 120, height: 120, borderRadius: 0 },
+    live: { type: 'live', liveUrl: '', playAudio: true, width: 200, height: 120, borderRadius: 0 },
+    webPage: { type: 'webPage', url: 'https://example.com', width: 300, height: 200, borderRadius: 0 },
+    clock: { type: 'clock', format: '24h', width: 200, height: 60 },
+  };
+
+  const newComponent = {
+    ...componentDefaults[dragType.value],
+    x: x / canvasScale.value,
+    y: y / canvasScale.value,
+    depth: getMaxDepth() + 1,
+  };
+
+  editorContent.value.elements.push(newComponent);
+  nextTick(() => selectComponent(newComponent));
   dragType.value = null;
 }
 
@@ -650,34 +520,26 @@ function updateContainerSize() {
 onMounted(async () => {
   nextTick(updateContainerSize);
   window.addEventListener('resize', updateContainerSize);
-  // 获取节目详细信息并补充到 editorContent
   try {
     const res = await getItemProgram(id.value);
-    let name = res.data?.name || '';
-    let resolutionRatio = res.data?.resolutionRatio || '';
-    let parsed: any = { elements: [] };
-    if (res.data && res.data.itemJsonStr) {
+    const data = res.data;
+    let parsed = { elements: [] };
+    if (data && data.itemJsonStr) {
       try {
-        parsed = JSON.parse(res.data.itemJsonStr);
+        parsed = JSON.parse(data.itemJsonStr);
       } catch (err) {
         parsed = { elements: [] };
       }
     }
-    // 合并 name、resolutionRatio 字段,保证结构完整
     editorContent.value = {
       ...parsed,
-      name,
-      resolutionRatio,
+      name: data?.name || '',
+      resolutionRatio: data?.resolutionRatio || '',
       elements: Array.isArray(parsed.elements) ? parsed.elements : []
     };
     ensureCanvasAndDepth(editorContent.value.elements, editorContent.value.resolutionRatio);
   } catch (e) {
-    // fallback: 初始化 elements 并插入默认画布,且补齐基础字段
-    editorContent.value = {
-      name: '',
-      resolutionRatio: '',
-      elements: []
-    };
+    editorContent.value = { name: '', resolutionRatio: '', elements: [] };
     ensureCanvasAndDepth(editorContent.value.elements);
   }
 });
@@ -688,37 +550,29 @@ const canvasScale = computed(() => {
   if (!canvas.value) return 1;
   const cW = Number(canvas.value.width) || 600;
   const cH = Number(canvas.value.height) || 400;
-  const boxW = containerSize.value.width;
-  const boxH = containerSize.value.height;
+  const boxW = containerSize.value.width - 40; // a little padding
+  const boxH = containerSize.value.height - 40;
   if (!boxW || !boxH) return 1;
   return Math.min(boxW / cW, boxH / cH, 1);
 });
 
-// 修复:为模板提供 canvasItem 变量
 const canvasItem = computed(() => editorContent.value.elements.find((el: any) => el.type === 'canvas'));
-
-// 右侧属性栏:节目名称和分辨率
-console.log(editorContent.value);
 const programName = computed(() => editorContent.value.name || '-');
 const programResolution = computed(() => {
-  // 优先取 editorContent.value.resolutionRatio,其次 canvas 宽高
   if (editorContent.value.resolutionRatio) return editorContent.value.resolutionRatio;
-  const canvas = editorContent.value.elements?.find((el: any) => el.type === 'canvas');
-  if (canvas && canvas.width && canvas.height) return `${canvas.width}x${canvas.height}`;
-  return '-';
+  const c = canvas.value;
+  return c && c.width && c.height ? `${c.width}x${c.height}` : '-';
 });
 
 const saveLoading = ref(false);
 const handleSave = async () => {
   saveLoading.value = true;
   try {
-    // 先获取后端原始数据,避免遗漏字段
     const res = await getItemProgram(id.value);
     const data = res.data || {};
-    // 用最新 JSON 覆盖
     data.itemJsonStr = JSON.stringify(editorContent.value);
     await updateItemProgram(data);
-    ElMessage.success('保存成功,所有数据已同步到数据库');
+    ElMessage.success('保存成功');
   } catch (e) {
     ElMessage.error('保存失败,请重试');
   } finally {
@@ -732,292 +586,262 @@ const goBack = () => {
 </script>
 
 <style scoped>
+/* 主布局 */
 .edit-program-layout {
   display: flex;
-  flex-direction: row;
   height: 100vh;
-  background: #f6f8fa;
-  min-width: 900px;
+  background-color: #f7f8fa;
+  overflow: hidden;
 }
 
-.sidebar-recycle-bin {
+/* 左侧边栏 */
+.sidebar {
+  width: 220px;
+  background-color: #ffffff;
+  border-right: 1px solid #e4e7ed;
+  border-left: 1px solid #e4e7ed;
   display: flex;
   flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  margin-top: 20px;
-  padding: 8px 0 0 0;
-  border-top: 1px dashed #eee;
-  cursor: pointer;
-  transition: background 0.2s;
+  transition: width 0.3s ease;
 }
-
-.sidebar {
-  width: 90px;
-  background: #232a36;
-  color: #fff;
+.sidebar-header {
   display: flex;
-  flex-direction: column;
   align-items: center;
-  padding-top: 18px;
-  padding-bottom: 24px;
-  box-sizing: border-box;
-  height: 100vh;
+  padding: 16px;
+  border-bottom: 1px solid #e4e7ed;
+  margin-top: 10px;
+}
+.back-btn {
+  margin-right: 12px;
 }
-
 .sidebar-title {
   font-size: 16px;
-  margin-bottom: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.sidebar-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
 }
-
 .sidebar-item {
   display: flex;
-  flex-direction: column;
   align-items: center;
-  margin-bottom: 18px;
-  cursor: pointer;
-  width: 90%;
-  border-radius: 0 !important;
-}
-
-.sidebar-icon {
-  width: 34px;
-  height: 34px;
+  padding: 10px 12px;
   margin-bottom: 4px;
+  border-radius: 6px;
+  cursor: grab;
+  transition: background-color 0.2s ease, box-shadow 0.2s ease;
+  user-select: none;
+  border: 1px solid transparent;
+}
+.sidebar-item:hover {
+  background-color: #f5f7fa;
+}
+.sidebar-item.selected {
+  background-color: #ecf5ff;
+  border-color: #b3d8ff;
+  color: #409eff;
+}
+.component-icon-text {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  color: #606266;
+}
+.sidebar-item.selected .component-icon-text {
+  color: #409eff;
+  font-weight: 500;
 }
 
-.sidebar-icon.text {
-  width: 34px;
-  height: 34px;
-  background: #fff;
-  color: #232a36;
+.sidebar-recycle-bin {
   display: flex;
   align-items: center;
   justify-content: center;
-  font-weight: bold;
-  font-size: 22px;
-  border-radius: 0 !important;
-  margin-bottom: 4px;
+  padding: 16px;
+  margin: 8px;
+  border-radius: 6px;
+  border: 1px dashed #dcdfe6;
+  color: #909399;
+  transition: all 0.2s ease;
+}
+.sidebar-recycle-bin:hover,
+.sidebar-recycle-bin[drag-over] {
+  border-color: #f56c6c;
+  background-color: #fef0f0;
+  color: #f56c6c;
+}
+.sidebar-recycle-bin .el-icon {
+  margin-right: 8px;
 }
 
+/* 中间编辑区 */
 .main-editor {
   flex: 1;
   display: flex;
   flex-direction: column;
-  align-items: center;
-  justify-content: center;
   min-width: 0;
-  min-height: 0;
-  position: relative;
 }
-
-.editor-canvas {
-  width: 90%;
-  height: 75%;
-  background: #e9eef3;
-  border-radius: 0px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-bottom: 24px;
-  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
-  position: relative;
-  overflow: hidden;
-}
-
 .toolbar {
   display: flex;
-  flex-direction: row;
-  align-self: flex-start;
   align-items: center;
-  height: 50px;
-  width: 700px;
-  margin-left: 5%;
-  margin-top: -5%;
-  margin-bottom: 1%;
-  background: #fafbfc;
-  border-radius: 0px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
-  gap: 16px;
-  padding: 0 16px;
+  padding: 8px 24px;
+  background-color: #ffffff;
+  border-bottom: 1px solid #e4e7ed;
+  gap: 12px;
+  margin-top: 7px;
 }
-
 .toolbar-item {
-  user-select: none;
-  cursor: grab;
-  background: #fff;
-  border-radius: 0px;
-  /* margin: 8px 0 8px 8px; */
-  width: 92px;
-  min-height: 50px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  flex: 1 1 0;
   display: flex;
+  flex-direction: column;
   align-items: center;
   justify-content: center;
-  font-size: 24px;
-  font-weight: 500;
-  letter-spacing: 1px;
-  text-align: center;
-  transition: background 0.2s;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
-  /* padding: 0 8px; */
+  padding: 6px 12px;
+  border-radius: 6px;
+  cursor: grab;
+  transition: background-color 0.2s ease;
+  user-select: none;
+  font-size: 12px;
+  color: #606266;
+}
+.toolbar-item .el-icon {
+  font-size: 20px;
+  margin-bottom: 4px;
+}
+.toolbar-item:hover {
+  background-color: #f5f7fa;
 }
-
 .toolbar-item:active {
-  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.13);
-  border-color: #409eff;
+  cursor: grabbing;
+  background-color: #f0f2f5;
 }
 
-.canvas-content {
+.editor-canvas-wrapper {
+  flex: 1;
   display: flex;
-  flex-direction: column;
   align-items: center;
   justify-content: center;
+  padding: 20px;
+  overflow: auto;
 }
-
-.canvas-icon {
-  width: 120px;
-  height: 120px;
-  margin-bottom: 18px;
+.editor-canvas {
+  /* No fixed size, will be determined by its content wrapper */
 }
 
-.canvas-text {
-  color: #222;
-  font-size: 22px;
+.bottom-action-bar {
+  padding: 12px 24px;
+  background-color: #ffffff;
+  border-top: 1px solid #e4e7ed;
+  display: flex;
+  justify-content: flex-end;
 }
-
 .save-btn {
-  align-self: flex-end;
-  margin-right: 8vw;
+  width: 120px;
+  height: 36px;
 }
 
-.back-btn {
-  margin-bottom: 16px;
-  background: #fff;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
-  border: none;
-  margin-left: auto;
-  margin-right: auto;
-}
 
-.property-info {
-  margin-bottom: 18px;
+/* 右侧属性面板 */
+.property-panel {
+  width: 280px;
+  background-color: #ffffff;
+  border-left: 1px solid #e4e7ed;
+  display: flex;
+  flex-direction: column;
+  transition: width 0.3s ease;
 }
-
-.property-info-row {
+.panel-header {
+  padding: 16px;
+  border-bottom: 1px solid #e4e7ed;
+  height: 65px;
   display: flex;
   align-items: center;
-  margin-bottom: 6px;
+  justify-content: center;
+  margin-top: 10px;
 }
-
-.property-info-label {
-  color: #888;
-  min-width: 72px;
-  font-weight: 500;
+.panel-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0;
+  padding: 0;
 }
-
-.property-panel {
-  width: 260px;
-  background: #fff;
-  box-shadow: -2px 0 8px rgba(0, 0, 0, 0.03);
-  padding: 32px 18px 0 18px;
-  display: flex;
-  flex-direction: column;
-  height: 100vh;
-  position: relative;
-  border-left: 1px solid #ececec;
-  box-sizing: border-box;
-  justify-content: flex-start;
+.panel-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
 }
-
-.property-form-area {
-  flex: 0 0 auto;
+.property-info-card {
+  background-color: #f5f7fa;
+  border-radius: 6px;
+  padding: 12px;
+  margin-bottom: 16px;
 }
-
-.property-divider {
-  height: 1px;
-  background: #ececec;
-  margin: 18px 0 12px 0;
-  width: 100%;
-  border: none;
+.property-info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  font-size: 14px;
 }
-
-.el-button+.el-button {
-  margin-left: 0px;
+.property-info-row:last-child {
+  margin-bottom: 0;
 }
-
-.json-debug-title {
-  margin-top: 30px;
-  font-size: 14px;
-  color: #888;
-  font-weight: bold;
+.property-info-label {
+  color: #909399;
+}
+.property-info-value {
+  color: #606266;
+  font-weight: 500;
 }
-
 .json-debug {
   margin-top: 8px;
-  font-size: 13px;
-  background: #f6f8fa;
-  color: #222;
-  font-family: 'Fira Mono', 'Consolas', monospace;
+  font-size: 12px;
 }
 
-.property-title {
-  font-size: 16px;
-  margin-bottom: 18px;
+/* 属性表单美化 */
+.property-panel :deep(.el-form-item) {
+  margin-bottom: 16px;
 }
-
-.canvas-default {
-  width: 600px;
-  height: 400px;
-  background: #fff;
-  margin: 0 auto;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 20px;
-  color: #aaa;
-  border-radius: 0px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+.property-panel :deep(.el-form-item__label) {
+  color: #606266;
+  line-height: 22px !important;
+  margin-bottom: 4px !important;
+}
+.property-panel :deep(.el-form-item__content) {
+  line-height: normal;
 }
 
-.component-item {
-  border: 2px solid #e3e3e3;
-  border-radius: 0px;
-  background: #fafbfc;
+/* 对齐工具 */
+.align-tool-group {
   margin-bottom: 16px;
-  padding: 18px 0;
-  text-align: center;
-  cursor: pointer;
-  transition:
-    border-color 0.2s,
-    box-shadow 0.2s;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 16px;
-  font-weight: 500;
 }
-
-.component-item.selected {
-  border-color: #409eff;
-  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
-  background: #eaf6ff;
+.align-tool-title {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
 }
-
-.component-icon-text {
+.align-tool-group .el-button-group {
+  width: 100%;
   display: flex;
-  align-items: center;
-  justify-content: center;
+}
+.align-tool-group .el-button-group .el-button {
+  flex: 1;
+}
+.align-buttons {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 8px;
+}
+.align-buttons .el-button {
+  margin: 0;
   width: 100%;
-  font-size: 18px;
-  color: #222;
 }
-
-.sidebar-item-disabled {
-  pointer-events: none;
-  opacity: 0.6;
+.align-buttons .el-icon {
+  font-size: 16px;
 }
+.align-buttons .el-button:nth-child(1) .el-icon { transform: rotate(-90deg); } /* left */
+.align-buttons .el-button:nth-child(2) .el-icon { transform: rotate(0deg); } /* top */
+.align-buttons .el-button:nth-child(3) .el-icon { transform: rotate(90deg); } /* right */
+.align-buttons .el-button:nth-child(4) .el-icon { transform: rotate(180deg); } /* bottom */
 </style>

+ 1023 - 0
smsb-plus-ui/src/views/smsb/itemProgram/EditProgram.vue_old

@@ -0,0 +1,1023 @@
+<template>
+  <div class="edit-program-layout">
+    <!-- 左侧组件栏及返回按钮 -->
+    <div class="sidebar">
+      <el-button class="back-btn" type="default" @click="goBack" circle>
+        <el-icon>
+          <ArrowLeft />
+        </el-icon>
+      </el-button>
+      <div class="sidebar-title">组件</div>
+      <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)" draggable="true"
+          @dragstart="onSidebarDragStart(item, idx, $event)" @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>
+            <template v-else-if="item.type === 'live'">直播</template>
+            <template v-else-if="item.type === 'webPage'">网页</template>
+            <template v-else-if="item.type === 'clock'">时钟</template>
+            <!-- 未来可扩展图片等类型 -->
+          </div>
+        </div>
+      </template>
+      <div class="sidebar-recycle-bin" @dragover.prevent @drop="onRecycleDrop($event)" title="拖动组件到此处删除">
+        <el-icon style="font-size: 28px; color: #bbb">
+          <svg viewBox="0 0 1024 1024" width="1em" height="1em">
+            <path
+              d="M320 896c0 35.2 28.8 64 64 64h256c35.2 0 64-28.8 64-64V320H320v576z m352-704V160c0-35.2-28.8-64-64-64H416c-35.2 0-64 28.8-64 64v32H160v64h704v-64H672z m-64 0H416V160h192v32z"
+              fill="currentColor"></path>
+          </svg>
+        </el-icon>
+        <div style="font-size: 12px; color: #bbb">拖拽移除组件</div>
+      </div>
+    </div>
+
+    <!-- 中间编辑区 -->
+    <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 class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('live')">直播</div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('webPage')">网页</div>
+        <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('clock')">时钟</div>
+        <!-- 可扩展更多组件 -->
+      </div>
+      <div class="editor-canvas" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
+        <CanvasBoard v-if="canvasItem" :width="canvasItem.width" :height="canvasItem.height" :bg="canvasItem.bg"
+          :scale="canvasScale" @click.stop="selectComponent(canvasItem)"
+          :class="{ selected: selectedComponent === canvasItem }">
+          <template #default>
+            <template
+              v-for="item in editorContent.elements.filter((el) => el.type !== 'canvas').sort((a, b) => a.depth - b.depth)"
+              :key="item.depth + '-' + item.type">
+              <div :style="{
+                position: 'absolute',
+                left: (item.x || 0) * canvasScale + 'px',
+                top: (item.y || 0) * canvasScale + 'px',
+                width: (item.width || 200) * canvasScale + 'px',
+                height: (item.height || 40) * canvasScale + 'px',
+                cursor: draggingId === item.depth ? 'grabbing' : 'move',
+                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" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :selected="selectedComponent === item" @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @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 * canvasScale" :height="item.height * canvasScale" :speed="item.speed"
+                  :selected="selectedComponent === item" @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :media-id="item.mediaId" :selected="selectedComponent === item"
+                  v-model="item.mediaGroup" :border-radius="item.borderRadius || 0" @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio"
+                  :selected="selectedComponent === item" :border-radius="item.borderRadius || 0" @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item"
+                  :border-radius="item.borderRadius || 0" @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                <ClockBoard v-if="item.type === 'clock'" :width="item.width * canvasScale"
+                  :height="item.height * canvasScale" :format="item.format" :selected="selectedComponent === item"
+                  @resize="
+                    ({ width, height }) => {
+                      item.width = width / canvasScale;
+                      item.height = height / canvasScale;
+                    }
+                  " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
+                <!-- 未来可扩展更多类型 -->
+              </div>
+            </template>
+          </template>
+        </CanvasBoard>
+      </div>
+      <el-button type="primary" class="save-btn" :loading="saveLoading" :disabled="saveLoading"
+        @click="handleSave">保存</el-button>
+    </div>
+
+    <!-- 右侧属性栏 -->
+    <div class="property-panel">
+      <div class="property-title">属性</div>
+      <div class="property-form-area">
+        <div class="property-info">
+          <div class="property-info-row">
+            <span class="property-info-label">节目名称:</span>
+            <span>{{ programName }}</span>
+          </div>
+          <div class="property-info-row">
+            <span class="property-info-label">分辨率:</span>
+            <span>{{ programResolution }}</span>
+          </div>
+        </div>
+        <!-- 动态显示选中组件的可编辑属性 -->
+        <template v-if="selectedComponent">
+          <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)">
+              <template v-if="selectedComponent.type === 'live' && key === 'playAudio'">
+                <el-switch v-model="selectedComponent[key]" active-text="开" inactive-text="关" />
+              </template>
+              <template v-else-if="key === 'mediaId'">
+                <CarouselGroupSelector v-model="selectedComponent[key]" :showSelected="false" />
+              </template>
+              <template v-else-if="key === 'bg'">
+                <BackgroundSelector v-model="selectedComponent[key]" :isCanvas="selectedComponent.type === 'canvas'" />
+              </template>
+              <template
+                v-else-if="key === 'color' && (selectedComponent.type === 'text' || selectedComponent.type === 'scrollingText')">
+                <BackgroundSelector v-model="selectedComponent[key]" :isCanvas="false" />
+              </template>
+              <template v-else-if="selectedComponent.type === 'clock' && key === 'format'">
+                <el-select v-model="selectedComponent[key]" style="width: 100%">
+                  <el-option label="24小时制 (HH:mm:ss)" value="24h" />
+                  <el-option label="12小时制 (hh:mm:ss A)" value="12h" />
+                  <el-option label="日期+时间 (YYYY-MM-DD HH:mm:ss)" value="date" />
+                </el-select>
+              </template>
+              <template v-else-if="key === 'borderRadius'">
+                <el-input-number v-model="selectedComponent[key]" :min="0" :max="100" :step="1" style="width: 100%" />
+              </template>
+              <template v-else>
+                <el-input v-model="selectedComponent[key]" />
+              </template>
+            </el-form-item>
+          </template>
+          <!-- 对齐尺寸操作区 -->
+          <div style="margin: 12px 0">
+            <div style="font-weight: bold; margin-bottom: 10px">对齐尺寸</div>
+            <!-- 第一组 2x2 -->
+            <div
+              style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
+              <el-button size="small" style="width: 100%" @click="alignComponent('left')">水平靠左</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('right')">水平靠右</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('top')">垂直靠上</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('bottom')">垂直靠下</el-button>
+            </div>
+            <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
+            <!-- 第二组 2x2 -->
+            <div
+              style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
+              <el-button size="small" style="width: 100%" @click="alignComponent('width-full')">宽铺满</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('width-half')">宽半屏</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('width-third')">宽1/3屏</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('width-quarter')">宽1/4屏</el-button>
+            </div>
+            <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
+            <!-- 第三组 2x2 -->
+            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 4px; padding-left: 2px">
+              <el-button size="small" style="width: 100%" @click="alignComponent('height-full')">高铺满</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('height-half')">高半屏</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('height-third')">高1/3屏</el-button>
+              <el-button size="small" style="width: 100%" @click="alignComponent('height-quarter')">高1/4屏</el-button>
+            </div>
+          </div>
+        </template>
+        <template v-else>
+          <div style="color: #bbb">请点击编辑区中的组件以编辑属性</div>
+        </template>
+      </div>
+      <hr class="property-divider" />
+      <template v-if="isLocalDev">
+        <div class="json-debug-title">当前JSON</div>
+        <el-input class="json-debug" type="textarea" :rows="8" :model-value="JSON.stringify(editorContent, null, 2)"
+          readonly />
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+// 本地开发环境开关,正式环境请设为 false 或用 import.meta.env 读取
+const isLocalDev = false;
+// 对齐尺寸操作
+function alignComponent(type: string) {
+  if (!selectedComponent.value || selectedComponent.value.type === 'canvas') return;
+  // 找到canvas尺寸
+  const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
+  if (!canvas) return;
+  const cW = Number(canvas.width) || 600;
+  const cH = Number(canvas.height) || 400;
+  // 只操作当前选中组件
+  const comp = selectedComponent.value;
+  switch (type) {
+    case 'left':
+      comp.x = 0;
+      break;
+    case 'right':
+      comp.x = cW - (Number(comp.width) || 0);
+      break;
+    case 'top':
+      comp.y = 0;
+      break;
+    case 'bottom':
+      comp.y = cH - (Number(comp.height) || 0);
+      break;
+    case 'width-full':
+      comp.x = 0;
+      comp.width = cW;
+      break;
+    case 'width-half':
+      comp.x = 0;
+      comp.width = Math.round(cW / 2);
+      break;
+    case 'width-third':
+      comp.x = 0;
+      comp.width = Math.round(cW / 3);
+      break;
+    case 'width-quarter':
+      comp.x = 0;
+      comp.width = Math.round(cW / 4);
+      break;
+    case 'height-full':
+      comp.y = 0;
+      comp.height = cH;
+      break;
+    case 'height-half':
+      comp.y = 0;
+      comp.height = Math.round(cH / 2);
+      break;
+    case 'height-third':
+      comp.y = 0;
+      comp.height = Math.round(cH / 3);
+      break;
+    case 'height-quarter':
+      comp.y = 0;
+      comp.height = Math.round(cH / 4);
+      break;
+  }
+}
+
+// 拖拽排序相关
+const sidebarDrag = ref<{ item: any; idx: number } | null>(null);
+
+// 回收站拖拽释放事件,移除对应组件
+function onRecycleDrop(e: DragEvent) {
+  const depth = e.dataTransfer?.getData('component-depth');
+  if (!depth) return;
+  // 不能删除画布类型
+  const idx = editorContent.value.elements.findIndex((el) => String(el.depth) === depth && el.type !== 'canvas');
+  if (idx > -1) {
+    editorContent.value.elements.splice(idx, 1);
+    ElMessage.success('组件已移除');
+  }
+}
+
+// 拖拽开始时,将组件depth放入dataTransfer
+function onSidebarDragStart(item: any, idx: number, e?: DragEvent) {
+  sidebarDrag.value = { item, idx };
+  if (e && e.dataTransfer) {
+    e.dataTransfer.setData('component-depth', String(item.depth));
+  }
+}
+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 CarouselGroupSelector from '@/components/CarouselGroupSelector.vue';
+import BackgroundSelector from '@/components/BackgroundSelector.vue';
+import LiveBoard from './component/LiveBoard.vue';
+import WebPageBoard from './component/WebPageBoard.vue';
+import ClockBoard from './component/ClockBoard.vue';
+import {
+  canvasPropNameMap,
+  textPropNameMap,
+  scrollingTextPropNameMap,
+  mediaAssetPropNameMap,
+  livePropNameMap,
+  webPagePropNameMap,
+  clockPropNameMap
+} 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';
+import { getItemProgram, updateItemProgram } from '@/api/smsb/source/item_program';
+
+const route = useRoute();
+
+// 当前选中组件
+const selectedComponent = ref<any>(null);
+
+// 选中组件方法
+function selectComponent(item: any) {
+  selectedComponent.value = item;
+}
+
+// 左侧栏点击选中画布
+function selectCanvasFromSidebar() {
+  const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
+  if (canvas) {
+    selectedComponent.value = canvas;
+  }
+}
+
+// 属性栏显示哪些属性可编辑(可根据实际需求过滤)
+function showEditableProp(key: string) {
+  // 明确排除 type 字段,防止被编辑
+  if (!selectedComponent.value) return false;
+  // 对于文本、滚动文本和时钟组件,移除 'fontWeight' 和 'align' 属性
+  if (
+    (selectedComponent.value.type === 'text' || selectedComponent.value.type === 'scrollingText' || selectedComponent.value.type === 'clock') &&
+    (key === 'fontWeight' || key === 'align')
+  ) {
+    return false;
+  }
+  // 画布、文本、滚动文本和时钟组件不显示 borderRadius 属性
+  if (
+    (selectedComponent.value.type === 'canvas' ||
+      selectedComponent.value.type === 'text' ||
+      selectedComponent.value.type === 'scrollingText' ||
+      selectedComponent.value.type === 'clock') &&
+    key === 'borderRadius'
+  ) {
+    return false;
+  }
+  return !['type', 'depth'].includes(key);
+}
+
+// 获取属性中文名
+function getPropLabel(key: string) {
+  if (selectedComponent.value?.type === 'canvas') {
+    return canvasPropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'text') {
+    return textPropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'scrollingText') {
+    return scrollingTextPropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'mediaAsset') {
+    return mediaAssetPropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'live') {
+    return livePropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'webPage') {
+    return webPagePropNameMap[key] || key;
+  } else if (selectedComponent.value?.type === 'clock') {
+    return clockPropNameMap[key] || key;
+  }
+  return key;
+}
+
+const router = useRouter();
+// 自动修正 id 类型,确保为 string 或 number
+const rawId = route.params.id;
+const id = ref<string | number>(Array.isArray(rawId) ? rawId[0] : rawId);
+
+// 自动填充画布分辨率
+onMounted(async () => {
+  try {
+    const res = await getItemProgram(id.value);
+    const data = res.data;
+    let resolutionRatio = '';
+    if (data && data.resolutionRatio) {
+      resolutionRatio = data.resolutionRatio;
+    }
+    // 优先使用后端返回的 itemJsonStr 字段
+    let parsed = { elements: [] };
+    if (data && data.itemJsonStr) {
+      try {
+        parsed = JSON.parse(data.itemJsonStr);
+      } catch (err) {
+        // 解析失败则回退到空布局
+        parsed = { elements: [] };
+      }
+    }
+    editorContent.value = parsed;
+    ensureCanvasAndDepth(editorContent.value.elements, resolutionRatio);
+  } catch (e) {
+    // fallback: 初始化 elements 并插入默认画布
+    editorContent.value = { elements: [] };
+    ensureCanvasAndDepth(editorContent.value.elements);
+  }
+});
+
+function ensureCanvasAndDepth(elements, resolutionRatio?: string) {
+  let width = 600,
+    height = 400;
+  if (resolutionRatio) {
+    const [w, h] = resolutionRatio.split('x').map(Number);
+    if (w && h) {
+      width = w;
+      height = h;
+      // console.log('#136: ', width, height);
+    }
+  }
+  // 检查是否有 type: 'canvas' 的组件
+  let idx = elements.findIndex((el) => el.type === 'canvas');
+  if (idx === -1) {
+    // console.log('#141: ', width, height);
+    elements.unshift({ type: 'canvas', width, height, bg: '#fff', depth: 0 });
+  } else {
+    let canvas = elements[idx];
+    let changed = false;
+    if (!canvas.width) {
+      canvas = { ...canvas, width };
+      changed = true;
+    }
+    if (!canvas.height) {
+      canvas = { ...canvas, height };
+      changed = true;
+    }
+    if (changed) {
+      elements[idx] = canvas; // 替换整个对象,确保响应式
+      // console.log('#145: ', width, height, '响应式canvas:', canvas);
+    } else {
+      // console.log('#145: ', width, height);
+    }
+  }
+  // 按 depth 排序,如果没有 depth 则补齐
+  elements.forEach((el, idx) => {
+    if (typeof el.depth !== 'number') {
+      el.depth = el.type === 'canvas' ? 0 : idx + 1;
+    }
+  });
+  elements.sort((a, b) => a.depth - b.depth);
+  return elements;
+}
+
+interface EditorContent {
+  name?: string;
+  resolutionRatio?: string;
+  elements: any[];
+  [key: string]: any;
+}
+const editorContent = ref<EditorContent>({ elements: [] });
+
+// editor-canvas 缩放逻辑
+const editorCanvasRef = ref<HTMLElement | null>(null);
+const containerSize = ref({ width: 0, height: 0 });
+
+const draggingId = ref<number | null>(null);
+let dragStart = { x: 0, y: 0, offsetX: 0, offsetY: 0 };
+
+function onElementMouseDown(e: MouseEvent, item: any) {
+  e.stopPropagation();
+  draggingId.value = item.depth;
+  dragStart = {
+    x: e.clientX,
+    y: e.clientY,
+    offsetX: item.x || 0,
+    offsetY: item.y || 0
+  };
+  document.addEventListener('mousemove', onElementMouseMove);
+  document.addEventListener('mouseup', onElementMouseUp);
+}
+
+function onElementMouseMove(e: MouseEvent) {
+  if (draggingId.value === null) return;
+  const item = editorContent.value.elements.find((el) => el.depth === draggingId.value);
+  if (!item) return;
+  // 拖拽时坐标除以缩放比例,保证拖拽速度和鼠标一致
+  item.x = dragStart.offsetX + (e.clientX - dragStart.x) / canvasScale.value;
+  item.y = dragStart.offsetY + (e.clientY - dragStart.y) / canvasScale.value;
+}
+
+function onElementMouseUp() {
+  draggingId.value = null;
+  document.removeEventListener('mousemove', onElementMouseMove);
+  document.removeEventListener('mouseup', onElementMouseUp);
+}
+
+function onToolbarDragStart(type: string) {
+  dragType.value = type;
+}
+
+function onCanvasDrop(e: DragEvent) {
+  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: x,
+      y: y,
+      width: 200,
+      height: 40,
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
+    };
+    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,
+      borderRadius: 0
+    };
+    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,
+      borderRadius: 0
+    };
+    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,
+      borderRadius: 0
+    };
+    editorContent.value.elements.push(newLive);
+    nextTick(() => selectComponent(newLive));
+  } else if (dragType.value === 'webPage') {
+    const newWebPage = {
+      type: 'webPage',
+      url: 'https://example.com',
+      x: x,
+      y: y,
+      width: 300,
+      height: 200,
+      depth: getMaxDepth() + 1,
+      borderRadius: 0
+    };
+    editorContent.value.elements.push(newWebPage);
+    nextTick(() => selectComponent(newWebPage));
+  } else if (dragType.value === 'clock') {
+    const newClock = {
+      type: 'clock',
+      format: '24h', // default format, can be '24h', '12h', or 'dateTime'
+      x: x,
+      y: y,
+      width: 200,
+      height: 60,
+      depth: getMaxDepth() + 1
+    };
+    editorContent.value.elements.push(newClock);
+    nextTick(() => selectComponent(newClock));
+  }
+  dragType.value = null;
+}
+
+function updateContainerSize() {
+  if (editorCanvasRef.value) {
+    containerSize.value.width = editorCanvasRef.value.clientWidth;
+    containerSize.value.height = editorCanvasRef.value.clientHeight;
+  }
+}
+
+onMounted(async () => {
+  nextTick(updateContainerSize);
+  window.addEventListener('resize', updateContainerSize);
+  // 获取节目详细信息并补充到 editorContent
+  try {
+    const res = await getItemProgram(id.value);
+    let name = res.data?.name || '';
+    let resolutionRatio = res.data?.resolutionRatio || '';
+    let parsed: any = { elements: [] };
+    if (res.data && res.data.itemJsonStr) {
+      try {
+        parsed = JSON.parse(res.data.itemJsonStr);
+      } catch (err) {
+        parsed = { elements: [] };
+      }
+    }
+    // 合并 name、resolutionRatio 字段,保证结构完整
+    editorContent.value = {
+      ...parsed,
+      name,
+      resolutionRatio,
+      elements: Array.isArray(parsed.elements) ? parsed.elements : []
+    };
+    ensureCanvasAndDepth(editorContent.value.elements, editorContent.value.resolutionRatio);
+  } catch (e) {
+    // fallback: 初始化 elements 并插入默认画布,且补齐基础字段
+    editorContent.value = {
+      name: '',
+      resolutionRatio: '',
+      elements: []
+    };
+    ensureCanvasAndDepth(editorContent.value.elements);
+  }
+});
+
+const canvas = computed(() => editorContent.value.elements.find((el: any) => el.type === 'canvas'));
+
+const canvasScale = computed(() => {
+  if (!canvas.value) return 1;
+  const cW = Number(canvas.value.width) || 600;
+  const cH = Number(canvas.value.height) || 400;
+  const boxW = containerSize.value.width;
+  const boxH = containerSize.value.height;
+  if (!boxW || !boxH) return 1;
+  return Math.min(boxW / cW, boxH / cH, 1);
+});
+
+// 修复:为模板提供 canvasItem 变量
+const canvasItem = computed(() => editorContent.value.elements.find((el: any) => el.type === 'canvas'));
+
+// 右侧属性栏:节目名称和分辨率
+console.log(editorContent.value);
+const programName = computed(() => editorContent.value.name || '-');
+const programResolution = computed(() => {
+  // 优先取 editorContent.value.resolutionRatio,其次 canvas 宽高
+  if (editorContent.value.resolutionRatio) return editorContent.value.resolutionRatio;
+  const canvas = editorContent.value.elements?.find((el: any) => el.type === 'canvas');
+  if (canvas && canvas.width && canvas.height) return `${canvas.width}x${canvas.height}`;
+  return '-';
+});
+
+const saveLoading = ref(false);
+const handleSave = async () => {
+  saveLoading.value = true;
+  try {
+    // 先获取后端原始数据,避免遗漏字段
+    const res = await getItemProgram(id.value);
+    const data = res.data || {};
+    // 用最新 JSON 覆盖
+    data.itemJsonStr = JSON.stringify(editorContent.value);
+    await updateItemProgram(data);
+    ElMessage.success('保存成功,所有数据已同步到数据库');
+  } catch (e) {
+    ElMessage.error('保存失败,请重试');
+  } finally {
+    saveLoading.value = false;
+  }
+};
+
+const goBack = () => {
+  router.push('/source/program');
+};
+</script>
+
+<style scoped>
+.edit-program-layout {
+  display: flex;
+  flex-direction: row;
+  height: 100vh;
+  background: #f6f8fa;
+  min-width: 900px;
+}
+
+.sidebar-recycle-bin {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 20px;
+  padding: 8px 0 0 0;
+  border-top: 1px dashed #eee;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.sidebar {
+  width: 90px;
+  background: #232a36;
+  color: #fff;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-top: 18px;
+  padding-bottom: 24px;
+  box-sizing: border-box;
+  height: 100vh;
+}
+
+.sidebar-title {
+  font-size: 16px;
+  margin-bottom: 18px;
+}
+
+.sidebar-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 18px;
+  cursor: pointer;
+  width: 90%;
+  border-radius: 0 !important;
+}
+
+.sidebar-icon {
+  width: 34px;
+  height: 34px;
+  margin-bottom: 4px;
+}
+
+.sidebar-icon.text {
+  width: 34px;
+  height: 34px;
+  background: #fff;
+  color: #232a36;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+  font-size: 22px;
+  border-radius: 0 !important;
+  margin-bottom: 4px;
+}
+
+.main-editor {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-width: 0;
+  min-height: 0;
+  position: relative;
+}
+
+.editor-canvas {
+  width: 90%;
+  height: 75%;
+  background: #e9eef3;
+  border-radius: 0px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 24px;
+  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
+  position: relative;
+  overflow: hidden;
+}
+
+.toolbar {
+  display: flex;
+  flex-direction: row;
+  align-self: flex-start;
+  align-items: center;
+  height: 50px;
+  width: 700px;
+  margin-left: 5%;
+  margin-top: -5%;
+  margin-bottom: 1%;
+  background: #fafbfc;
+  border-radius: 0px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+  gap: 16px;
+  padding: 0 16px;
+}
+
+.toolbar-item {
+  user-select: none;
+  cursor: grab;
+  background: #fff;
+  border-radius: 0px;
+  /* margin: 8px 0 8px 8px; */
+  width: 92px;
+  min-height: 50px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1 1 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  letter-spacing: 1px;
+  text-align: center;
+  transition: background 0.2s;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
+  /* padding: 0 8px; */
+}
+
+.toolbar-item:active {
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.13);
+  border-color: #409eff;
+}
+
+.canvas-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.canvas-icon {
+  width: 120px;
+  height: 120px;
+  margin-bottom: 18px;
+}
+
+.canvas-text {
+  color: #222;
+  font-size: 22px;
+}
+
+.save-btn {
+  align-self: flex-end;
+  margin-right: 8vw;
+}
+
+.back-btn {
+  margin-bottom: 16px;
+  background: #fff;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
+  border: none;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.property-info {
+  margin-bottom: 18px;
+}
+
+.property-info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+.property-info-label {
+  color: #888;
+  min-width: 72px;
+  font-weight: 500;
+}
+
+.property-panel {
+  width: 260px;
+  background: #fff;
+  box-shadow: -2px 0 8px rgba(0, 0, 0, 0.03);
+  padding: 32px 18px 0 18px;
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  position: relative;
+  border-left: 1px solid #ececec;
+  box-sizing: border-box;
+  justify-content: flex-start;
+}
+
+.property-form-area {
+  flex: 0 0 auto;
+}
+
+.property-divider {
+  height: 1px;
+  background: #ececec;
+  margin: 18px 0 12px 0;
+  width: 100%;
+  border: none;
+}
+
+.el-button+.el-button {
+  margin-left: 0px;
+}
+
+.json-debug-title {
+  margin-top: 30px;
+  font-size: 14px;
+  color: #888;
+  font-weight: bold;
+}
+
+.json-debug {
+  margin-top: 8px;
+  font-size: 13px;
+  background: #f6f8fa;
+  color: #222;
+  font-family: 'Fira Mono', 'Consolas', monospace;
+}
+
+.property-title {
+  font-size: 16px;
+  margin-bottom: 18px;
+}
+
+.canvas-default {
+  width: 600px;
+  height: 400px;
+  background: #fff;
+  margin: 0 auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  color: #aaa;
+  border-radius: 0px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.component-item {
+  border: 2px solid #e3e3e3;
+  border-radius: 0px;
+  background: #fafbfc;
+  margin-bottom: 16px;
+  padding: 18px 0;
+  text-align: center;
+  cursor: pointer;
+  transition:
+    border-color 0.2s,
+    box-shadow 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.component-item.selected {
+  border-color: #409eff;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
+  background: #eaf6ff;
+}
+
+.component-icon-text {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  font-size: 18px;
+  color: #222;
+}
+
+.sidebar-item-disabled {
+  pointer-events: none;
+  opacity: 0.6;
+}
+</style>

+ 1 - 1
smsb-plus-ui/src/views/smsb/playRecord/index.vue

@@ -205,7 +205,7 @@ const handleDelete = async (row?: SourcePlayRecordVO) => {
 
 /** 导出按钮操作 */
 const handleExport = () => {
-  proxy?.download('sourcePlayRecord/sourcePlayRecord/export', {
+  proxy?.download('source/playRecord/export', {
     ...queryParams.value
   }, `sourcePlayRecord_${new Date().getTime()}.xlsx`)
 }