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