|
|
@@ -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>
|