EditProgram.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. <template>
  2. <div class="edit-program-layout">
  3. <!-- 左侧组件栏及返回按钮 -->
  4. <div class="sidebar">
  5. <el-button class="back-btn" type="default" @click="goBack" circle>
  6. <el-icon>
  7. <ArrowLeft />
  8. </el-icon>
  9. </el-button>
  10. <div class="sidebar-title">组件</div>
  11. <template v-for="(item, idx) in editorContent.elements.slice().sort((a, b) => b.depth - a.depth)"
  12. :key="item.depth + '-' + item.type">
  13. <div class="sidebar-item component-item"
  14. :class="{ 'selected': selectedComponent === item || (item.type === 'canvas' && selectedComponent && selectedComponent.type === 'canvas') }"
  15. @click="item.type === 'canvas' ? selectCanvasFromSidebar() : selectComponent(item)" draggable="true"
  16. @dragstart="onSidebarDragStart(item, idx)" @dragover.prevent="onSidebarDragOver(item, idx, $event)"
  17. @drop.prevent="onSidebarDrop(item, idx, $event)">
  18. <div class="component-icon-text">
  19. <template v-if="item.type === 'canvas'">画布</template>
  20. <template v-else-if="item.type === 'text'">文本</template>
  21. <template v-else-if="item.type === 'scrollingText'">滚动文本</template>
  22. <template v-else-if="item.type === 'mediaAsset'">媒资</template>
  23. <template v-else-if="item.type === 'live'">直播</template>
  24. <template v-else-if="item.type === 'webPage'">网页</template>
  25. <!-- 未来可扩展图片等类型 -->
  26. </div>
  27. </div>
  28. </template>
  29. </div>
  30. <!-- 中间编辑区 -->
  31. <div class="main-editor">
  32. <div class="toolbar">
  33. <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('text')">文本</div>
  34. <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('scrollingText')">滚动文本</div>
  35. <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('mediaAsset')">媒资</div>
  36. <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('live')">直播</div>
  37. <div class="toolbar-item" draggable="true" @dragstart="onToolbarDragStart('webPage')">网页</div>
  38. <!-- 可扩展更多组件 -->
  39. </div>
  40. <div class="editor-canvas" ref="editorCanvasRef" @dragover.prevent @drop="onCanvasDrop">
  41. <CanvasBoard v-if="canvasItem" :width="canvasItem.width" :height="canvasItem.height" :bg="canvasItem.bg"
  42. :scale="canvasScale" @click.stop="selectComponent(canvasItem)"
  43. :class="{ selected: selectedComponent === canvasItem }">
  44. <template #default>
  45. <template
  46. v-for="item in editorContent.elements.filter((el) => el.type !== 'canvas').sort((a, b) => a.depth - b.depth)"
  47. :key="item.depth + '-' + item.type">
  48. <div :style="{
  49. position: 'absolute',
  50. left: (item.x || 0) * canvasScale + 'px',
  51. top: (item.y || 0) * canvasScale + 'px',
  52. width: (item.width || 200) * canvasScale + 'px',
  53. height: (item.height || 40) * canvasScale + 'px',
  54. cursor: draggingId === item.depth ? 'grabbing' : 'move',
  55. zIndex: 10
  56. }" @mousedown="onElementMouseDown($event, item)">
  57. <TextBoard v-if="item.type === 'text'" :text="item.text" :color="item.color" :font-size="item.fontSize"
  58. :font-weight="item.fontWeight" :align="item.align" :width="item.width * canvasScale"
  59. :height="item.height * canvasScale" :selected="selectedComponent === item" @resize="
  60. ({ width, height }) => {
  61. item.width = width / canvasScale;
  62. item.height = height / canvasScale;
  63. }
  64. " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
  65. <ScrollingTextBoard v-if="item.type === 'scrollingText'" :text="item.text" :color="item.color"
  66. :font-size="item.fontSize" :font-weight="item.fontWeight" :align="item.align"
  67. :width="item.width * canvasScale" :height="item.height * canvasScale" :speed="item.speed"
  68. :selected="selectedComponent === item" @resize="
  69. ({ width, height }) => {
  70. item.width = width / canvasScale;
  71. item.height = height / canvasScale;
  72. }
  73. " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
  74. <MediaAssetBoard v-if="item.type === 'mediaAsset'" :width="item.width * canvasScale"
  75. :height="item.height * canvasScale" :media-id="item.mediaId" :selected="selectedComponent === item"
  76. @resize="
  77. ({ width, height }) => {
  78. item.width = width / canvasScale;
  79. item.height = height / canvasScale;
  80. }
  81. " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
  82. <LiveBoard v-if="item.type === 'live'" :width="item.width * canvasScale"
  83. :height="item.height * canvasScale" :live-url="item.liveUrl" :play-audio="item.playAudio"
  84. :selected="selectedComponent === item" @resize="
  85. ({ width, height }) => {
  86. item.width = width / canvasScale;
  87. item.height = height / canvasScale;
  88. }
  89. " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
  90. <WebPageBoard v-if="item.type === 'webPage'" :width="item.width * canvasScale"
  91. :height="item.height * canvasScale" :url="item.url" :selected="selectedComponent === item" @resize="
  92. ({ width, height }) => {
  93. item.width = width / canvasScale;
  94. item.height = height / canvasScale;
  95. }
  96. " @click.stop="selectComponent(item)" :class="{ selected: selectedComponent === item }" />
  97. <!-- 未来可扩展更多类型 -->
  98. </div>
  99. </template>
  100. </template>
  101. </CanvasBoard>
  102. </div>
  103. <el-button type="primary" class="save-btn" :loading="saveLoading" :disabled="saveLoading"
  104. @click="handleSave">保存</el-button>
  105. </div>
  106. <!-- 右侧属性栏 -->
  107. <div class="property-panel">
  108. <div class="property-title">属性</div>
  109. <div class="property-form-area">
  110. <div class="property-info">
  111. <div class="property-info-row">
  112. <span class="property-info-label">节目名称:</span>
  113. <span>{{ programName }}</span>
  114. </div>
  115. <div class="property-info-row">
  116. <span class="property-info-label">分辨率:</span>
  117. <span>{{ programResolution }}</span>
  118. </div>
  119. </div>
  120. <!-- 动态显示选中组件的可编辑属性 -->
  121. <template v-if="selectedComponent">
  122. <div style="margin-bottom: 8px; font-weight: bold">组件属性</div>
  123. <template v-for="[key, value] in Object.entries(selectedComponent || {})" :key="key">
  124. <el-form-item v-if="showEditableProp(key)" :label="getPropLabel(key)">
  125. <template v-if="selectedComponent.type === 'live' && key === 'playAudio'">
  126. <el-switch v-model="selectedComponent[key]" active-text="开" inactive-text="关" />
  127. </template>
  128. <template v-else-if="key === 'mediaId'">
  129. <MediaFileSelector v-model="selectedComponent[key]" />
  130. </template>
  131. <template v-else>
  132. <el-input v-model="selectedComponent[key]" />
  133. </template>
  134. </el-form-item>
  135. </template>
  136. <!-- 对齐尺寸操作区 -->
  137. <div style="margin: 12px 0">
  138. <div style="font-weight: bold; margin-bottom: 10px">对齐尺寸</div>
  139. <!-- 第一组 2x2 -->
  140. <div
  141. style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
  142. <el-button size="small" style="width: 100%" @click="alignComponent('left')">水平靠左</el-button>
  143. <el-button size="small" style="width: 100%" @click="alignComponent('right')">水平靠右</el-button>
  144. <el-button size="small" style="width: 100%" @click="alignComponent('top')">垂直靠上</el-button>
  145. <el-button size="small" style="width: 100%" @click="alignComponent('bottom')">垂直靠下</el-button>
  146. </div>
  147. <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
  148. <!-- 第二组 2x2 -->
  149. <div
  150. style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; padding-left: 2px">
  151. <el-button size="small" style="width: 100%" @click="alignComponent('width-full')">宽铺满</el-button>
  152. <el-button size="small" style="width: 100%" @click="alignComponent('width-half')">宽半屏</el-button>
  153. <el-button size="small" style="width: 100%" @click="alignComponent('width-third')">宽1/3屏</el-button>
  154. <el-button size="small" style="width: 100%" @click="alignComponent('width-quarter')">宽1/4屏</el-button>
  155. </div>
  156. <hr style="border: none; border-top: 1px solid #eee; margin: 8px 0" />
  157. <!-- 第三组 2x2 -->
  158. <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 4px; padding-left: 2px">
  159. <el-button size="small" style="width: 100%" @click="alignComponent('height-full')">高铺满</el-button>
  160. <el-button size="small" style="width: 100%" @click="alignComponent('height-half')">高半屏</el-button>
  161. <el-button size="small" style="width: 100%" @click="alignComponent('height-third')">高1/3屏</el-button>
  162. <el-button size="small" style="width: 100%" @click="alignComponent('height-quarter')">高1/4屏</el-button>
  163. </div>
  164. </div>
  165. </template>
  166. <template v-else>
  167. <div style="color: #bbb">请点击编辑区中的组件以编辑属性</div>
  168. </template>
  169. </div>
  170. <hr class="property-divider" />
  171. <template v-if="isLocalDev">
  172. <div class="json-debug-title">当前JSON</div>
  173. <el-input class="json-debug" type="textarea" :rows="8" :model-value="JSON.stringify(editorContent, null, 2)"
  174. readonly />
  175. </template>
  176. </div>
  177. </div>
  178. </template>
  179. <script setup lang="ts">
  180. // 本地开发环境开关,正式环境请设为 false 或用 import.meta.env 读取
  181. const isLocalDev = false;
  182. // 对齐尺寸操作
  183. function alignComponent(type: string) {
  184. if (!selectedComponent.value || selectedComponent.value.type === 'canvas') return;
  185. // 找到canvas尺寸
  186. const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
  187. if (!canvas) return;
  188. const cW = Number(canvas.width) || 600;
  189. const cH = Number(canvas.height) || 400;
  190. // 只操作当前选中组件
  191. const comp = selectedComponent.value;
  192. switch (type) {
  193. case 'left':
  194. comp.x = 0;
  195. break;
  196. case 'right':
  197. comp.x = cW - (Number(comp.width) || 0);
  198. break;
  199. case 'top':
  200. comp.y = 0;
  201. break;
  202. case 'bottom':
  203. comp.y = cH - (Number(comp.height) || 0);
  204. break;
  205. case 'width-full':
  206. comp.x = 0;
  207. comp.width = cW;
  208. break;
  209. case 'width-half':
  210. comp.x = 0;
  211. comp.width = Math.round(cW / 2);
  212. break;
  213. case 'width-third':
  214. comp.x = 0;
  215. comp.width = Math.round(cW / 3);
  216. break;
  217. case 'width-quarter':
  218. comp.x = 0;
  219. comp.width = Math.round(cW / 4);
  220. break;
  221. case 'height-full':
  222. comp.y = 0;
  223. comp.height = cH;
  224. break;
  225. case 'height-half':
  226. comp.y = 0;
  227. comp.height = Math.round(cH / 2);
  228. break;
  229. case 'height-third':
  230. comp.y = 0;
  231. comp.height = Math.round(cH / 3);
  232. break;
  233. case 'height-quarter':
  234. comp.y = 0;
  235. comp.height = Math.round(cH / 4);
  236. break;
  237. }
  238. }
  239. // 拖拽排序相关
  240. const sidebarDrag = ref<{ item: any; idx: number } | null>(null);
  241. function onSidebarDragStart(item: any, idx: number) {
  242. sidebarDrag.value = { item, idx };
  243. }
  244. function onSidebarDragOver(targetItem: any, targetIdx: number, e: DragEvent) {
  245. e.preventDefault();
  246. }
  247. function onSidebarDrop(targetItem: any, targetIdx: number, e: DragEvent) {
  248. if (!sidebarDrag.value) return;
  249. const elements = editorContent.value.elements;
  250. // 排序前先按 depth 降序
  251. const sorted = elements.slice().sort((a, b) => b.depth - a.depth);
  252. const fromIdx = sorted.findIndex((el) => el === sidebarDrag.value!.item);
  253. const toIdx = sorted.findIndex((el) => el === targetItem);
  254. if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
  255. sidebarDrag.value = null;
  256. return;
  257. }
  258. // 交换depth
  259. const fromDepth = sorted[fromIdx].depth;
  260. const toDepth = sorted[toIdx].depth;
  261. sorted[fromIdx].depth = toDepth;
  262. sorted[toIdx].depth = fromDepth;
  263. // 重新赋值到原数组
  264. for (let i = 0; i < sorted.length; i++) {
  265. const origIdx = elements.findIndex((el) => el === sorted[i]);
  266. if (origIdx !== -1) elements[origIdx].depth = sorted[i].depth;
  267. }
  268. sidebarDrag.value = null;
  269. }
  270. import { ref, onMounted, computed, nextTick } from 'vue';
  271. import CanvasBoard from './component/CanvasBoard.vue';
  272. import TextBoard from './component/TextBoard.vue';
  273. import ScrollingTextBoard from './component/ScrollingTextBoard.vue';
  274. import MediaAssetBoard from './component/MediaAssetBoard.vue';
  275. import LiveBoard from './component/LiveBoard.vue';
  276. import WebPageBoard from './component/WebPageBoard.vue';
  277. import {
  278. canvasPropNameMap,
  279. textPropNameMap,
  280. scrollingTextPropNameMap,
  281. mediaAssetPropNameMap,
  282. livePropNameMap,
  283. webPagePropNameMap
  284. } from './component/propNameMaps';
  285. // 拖拽类型
  286. const dragType = ref<string | null>(null);
  287. // 获取最大 depth
  288. function getMaxDepth() {
  289. if (!editorContent.value.elements.length) return 0;
  290. return Math.max(...editorContent.value.elements.map((el: any) => el.depth || 0));
  291. }
  292. import { useRoute, useRouter } from 'vue-router';
  293. import { ElMessage } from 'element-plus';
  294. import { ArrowLeft } from '@element-plus/icons-vue';
  295. import { getItemProgram, updateItemProgram } from '@/api/smsb/source/item_program';
  296. const route = useRoute();
  297. // 当前选中组件
  298. const selectedComponent = ref<any>(null);
  299. // 选中组件方法
  300. function selectComponent(item: any) {
  301. selectedComponent.value = item;
  302. }
  303. // 左侧栏点击选中画布
  304. function selectCanvasFromSidebar() {
  305. const canvas = editorContent.value.elements.find((el: any) => el.type === 'canvas');
  306. if (canvas) {
  307. selectedComponent.value = canvas;
  308. }
  309. }
  310. // 属性栏显示哪些属性可编辑(可根据实际需求过滤)
  311. function showEditableProp(key: string) {
  312. // 明确排除 type 字段,防止被编辑
  313. if (!selectedComponent.value) return false;
  314. // 对于文本和滚动文本组件,移除 'fontWeight' 属性
  315. if ((selectedComponent.value.type === 'text' || selectedComponent.value.type === 'scrollingText') && (key === 'fontWeight' || key === 'align')) {
  316. return false;
  317. }
  318. return !['type', 'depth'].includes(key);
  319. }
  320. // 获取属性中文名
  321. function getPropLabel(key: string) {
  322. if (selectedComponent.value?.type === 'canvas') {
  323. return canvasPropNameMap[key] || key;
  324. } else if (selectedComponent.value?.type === 'text') {
  325. return textPropNameMap[key] || key;
  326. } else if (selectedComponent.value?.type === 'scrollingText') {
  327. return scrollingTextPropNameMap[key] || key;
  328. } else if (selectedComponent.value?.type === 'mediaAsset') {
  329. return mediaAssetPropNameMap[key] || key;
  330. } else if (selectedComponent.value?.type === 'live') {
  331. return livePropNameMap[key] || key;
  332. } else if (selectedComponent.value?.type === 'webPage') {
  333. return webPagePropNameMap[key] || key;
  334. }
  335. return key;
  336. }
  337. const router = useRouter();
  338. // 自动修正 id 类型,确保为 string 或 number
  339. const rawId = route.params.id;
  340. const id = ref<string | number>(Array.isArray(rawId) ? rawId[0] : rawId);
  341. // 自动填充画布分辨率
  342. onMounted(async () => {
  343. try {
  344. const res = await getItemProgram(id.value);
  345. const data = res.data;
  346. let resolutionRatio = '';
  347. if (data && data.resolutionRatio) {
  348. resolutionRatio = data.resolutionRatio;
  349. }
  350. // 优先使用后端返回的 itemJsonStr 字段
  351. let parsed = { elements: [] };
  352. if (data && data.itemJsonStr) {
  353. try {
  354. parsed = JSON.parse(data.itemJsonStr);
  355. } catch (err) {
  356. // 解析失败则回退到空布局
  357. parsed = { elements: [] };
  358. }
  359. }
  360. editorContent.value = parsed;
  361. ensureCanvasAndDepth(editorContent.value.elements, resolutionRatio);
  362. } catch (e) {
  363. // fallback: 初始化 elements 并插入默认画布
  364. editorContent.value = { elements: [] };
  365. ensureCanvasAndDepth(editorContent.value.elements);
  366. }
  367. });
  368. function ensureCanvasAndDepth(elements, resolutionRatio?: string) {
  369. let width = 600,
  370. height = 400;
  371. if (resolutionRatio) {
  372. const [w, h] = resolutionRatio.split('x').map(Number);
  373. if (w && h) {
  374. width = w;
  375. height = h;
  376. // console.log('#136: ', width, height);
  377. }
  378. }
  379. // 检查是否有 type: 'canvas' 的组件
  380. let idx = elements.findIndex((el) => el.type === 'canvas');
  381. if (idx === -1) {
  382. // console.log('#141: ', width, height);
  383. elements.unshift({ type: 'canvas', width, height, bg: '#fff', depth: 0 });
  384. } else {
  385. let canvas = elements[idx];
  386. let changed = false;
  387. if (!canvas.width) {
  388. canvas = { ...canvas, width };
  389. changed = true;
  390. }
  391. if (!canvas.height) {
  392. canvas = { ...canvas, height };
  393. changed = true;
  394. }
  395. if (changed) {
  396. elements[idx] = canvas; // 替换整个对象,确保响应式
  397. // console.log('#145: ', width, height, '响应式canvas:', canvas);
  398. } else {
  399. // console.log('#145: ', width, height);
  400. }
  401. }
  402. // 按 depth 排序,如果没有 depth 则补齐
  403. elements.forEach((el, idx) => {
  404. if (typeof el.depth !== 'number') {
  405. el.depth = el.type === 'canvas' ? 0 : idx + 1;
  406. }
  407. });
  408. elements.sort((a, b) => a.depth - b.depth);
  409. return elements;
  410. }
  411. interface EditorContent {
  412. name?: string;
  413. resolutionRatio?: string;
  414. elements: any[];
  415. [key: string]: any;
  416. }
  417. const editorContent = ref<EditorContent>({ elements: [] });
  418. // editor-canvas 缩放逻辑
  419. const editorCanvasRef = ref<HTMLElement | null>(null);
  420. const containerSize = ref({ width: 0, height: 0 });
  421. const draggingId = ref<number | null>(null);
  422. let dragStart = { x: 0, y: 0, offsetX: 0, offsetY: 0 };
  423. function onElementMouseDown(e: MouseEvent, item: any) {
  424. e.stopPropagation();
  425. draggingId.value = item.depth;
  426. dragStart = {
  427. x: e.clientX,
  428. y: e.clientY,
  429. offsetX: item.x || 0,
  430. offsetY: item.y || 0
  431. };
  432. document.addEventListener('mousemove', onElementMouseMove);
  433. document.addEventListener('mouseup', onElementMouseUp);
  434. }
  435. function onElementMouseMove(e: MouseEvent) {
  436. if (draggingId.value === null) return;
  437. const item = editorContent.value.elements.find((el) => el.depth === draggingId.value);
  438. if (!item) return;
  439. // 拖拽时坐标除以缩放比例,保证拖拽速度和鼠标一致
  440. item.x = dragStart.offsetX + (e.clientX - dragStart.x) / canvasScale.value;
  441. item.y = dragStart.offsetY + (e.clientY - dragStart.y) / canvasScale.value;
  442. }
  443. function onElementMouseUp() {
  444. draggingId.value = null;
  445. document.removeEventListener('mousemove', onElementMouseMove);
  446. document.removeEventListener('mouseup', onElementMouseUp);
  447. }
  448. function onToolbarDragStart(type: string) {
  449. dragType.value = type;
  450. }
  451. function onCanvasDrop(e: DragEvent) {
  452. if (!dragType.value) return;
  453. const rect = editorCanvasRef.value?.getBoundingClientRect();
  454. const x = e.clientX - (rect?.left || 0);
  455. const y = e.clientY - (rect?.top || 0);
  456. if (dragType.value === 'text') {
  457. const newText = {
  458. type: 'text',
  459. text: '新文本',
  460. color: '#222',
  461. fontSize: 24,
  462. fontWeight: 'normal',
  463. align: 'center',
  464. x: x,
  465. y: y,
  466. width: 200,
  467. height: 40,
  468. depth: getMaxDepth() + 1
  469. };
  470. editorContent.value.elements.push(newText);
  471. nextTick(() => selectComponent(newText));
  472. } else if (dragType.value === 'scrollingText') {
  473. const newScrollingText = {
  474. type: 'scrollingText',
  475. text: '新滚动文本',
  476. color: '#222',
  477. fontSize: 24,
  478. fontWeight: 'normal',
  479. align: 'center',
  480. speed: 50,
  481. x: x,
  482. y: y,
  483. width: 300,
  484. height: 40,
  485. depth: getMaxDepth() + 1
  486. };
  487. editorContent.value.elements.push(newScrollingText);
  488. nextTick(() => selectComponent(newScrollingText));
  489. } else if (dragType.value === 'mediaAsset') {
  490. const newMediaAsset = {
  491. type: 'mediaAsset',
  492. mediaId: '',
  493. x: x,
  494. y: y,
  495. width: 120,
  496. height: 120,
  497. depth: getMaxDepth() + 1
  498. };
  499. editorContent.value.elements.push(newMediaAsset);
  500. nextTick(() => selectComponent(newMediaAsset));
  501. } else if (dragType.value === 'live') {
  502. const newLive = {
  503. type: 'live',
  504. liveUrl: '',
  505. playAudio: true,
  506. x: x,
  507. y: y,
  508. width: 200,
  509. height: 120,
  510. depth: getMaxDepth() + 1
  511. };
  512. editorContent.value.elements.push(newLive);
  513. nextTick(() => selectComponent(newLive));
  514. } else if (dragType.value === 'webPage') {
  515. const newWebPage = {
  516. type: 'webPage',
  517. url: '',
  518. x: x,
  519. y: y,
  520. width: 120,
  521. height: 120,
  522. depth: getMaxDepth() + 1
  523. };
  524. editorContent.value.elements.push(newWebPage);
  525. nextTick(() => selectComponent(newWebPage));
  526. }
  527. dragType.value = null;
  528. }
  529. function updateContainerSize() {
  530. if (editorCanvasRef.value) {
  531. containerSize.value.width = editorCanvasRef.value.clientWidth;
  532. containerSize.value.height = editorCanvasRef.value.clientHeight;
  533. }
  534. }
  535. onMounted(async () => {
  536. nextTick(updateContainerSize);
  537. window.addEventListener('resize', updateContainerSize);
  538. // 获取节目详细信息并补充到 editorContent
  539. try {
  540. const res = await getItemProgram(id.value);
  541. let name = res.data?.name || '';
  542. let resolutionRatio = res.data?.resolutionRatio || '';
  543. let parsed: any = { elements: [] };
  544. if (res.data && res.data.itemJsonStr) {
  545. try {
  546. parsed = JSON.parse(res.data.itemJsonStr);
  547. } catch (err) {
  548. parsed = { elements: [] };
  549. }
  550. }
  551. // 合并 name、resolutionRatio 字段,保证结构完整
  552. editorContent.value = {
  553. ...parsed,
  554. name,
  555. resolutionRatio,
  556. elements: Array.isArray(parsed.elements) ? parsed.elements : []
  557. };
  558. ensureCanvasAndDepth(editorContent.value.elements, editorContent.value.resolutionRatio);
  559. } catch (e) {
  560. // fallback: 初始化 elements 并插入默认画布,且补齐基础字段
  561. editorContent.value = {
  562. name: '',
  563. resolutionRatio: '',
  564. elements: []
  565. };
  566. ensureCanvasAndDepth(editorContent.value.elements);
  567. }
  568. });
  569. const canvas = computed(() => editorContent.value.elements.find((el: any) => el.type === 'canvas'));
  570. const canvasScale = computed(() => {
  571. if (!canvas.value) return 1;
  572. const cW = Number(canvas.value.width) || 600;
  573. const cH = Number(canvas.value.height) || 400;
  574. const boxW = containerSize.value.width;
  575. const boxH = containerSize.value.height;
  576. if (!boxW || !boxH) return 1;
  577. return Math.min(boxW / cW, boxH / cH, 1);
  578. });
  579. // 修复:为模板提供 canvasItem 变量
  580. const canvasItem = computed(() => editorContent.value.elements.find((el: any) => el.type === 'canvas'));
  581. // 右侧属性栏:节目名称和分辨率
  582. console.log(editorContent.value);
  583. const programName = computed(() => editorContent.value.name || '-');
  584. const programResolution = computed(() => {
  585. // 优先取 editorContent.value.resolutionRatio,其次 canvas 宽高
  586. if (editorContent.value.resolutionRatio) return editorContent.value.resolutionRatio;
  587. const canvas = editorContent.value.elements?.find((el: any) => el.type === 'canvas');
  588. if (canvas && canvas.width && canvas.height) return `${canvas.width}x${canvas.height}`;
  589. return '-';
  590. });
  591. const saveLoading = ref(false);
  592. const handleSave = async () => {
  593. saveLoading.value = true;
  594. try {
  595. // 先获取后端原始数据,避免遗漏字段
  596. const res = await getItemProgram(id.value);
  597. const data = res.data || {};
  598. // 用最新 JSON 覆盖
  599. data.itemJsonStr = JSON.stringify(editorContent.value);
  600. await updateItemProgram(data);
  601. ElMessage.success('保存成功,所有数据已同步到数据库');
  602. } catch (e) {
  603. ElMessage.error('保存失败,请重试');
  604. } finally {
  605. saveLoading.value = false;
  606. }
  607. };
  608. const goBack = () => {
  609. router.push('/source/program');
  610. };
  611. </script>
  612. <style scoped>
  613. .edit-program-layout {
  614. display: flex;
  615. flex-direction: row;
  616. height: 100vh;
  617. background: #f6f8fa;
  618. min-width: 900px;
  619. }
  620. .sidebar {
  621. width: 90px;
  622. background: #232a36;
  623. color: #fff;
  624. display: flex;
  625. flex-direction: column;
  626. align-items: center;
  627. padding-top: 18px;
  628. padding-bottom: 24px;
  629. box-sizing: border-box;
  630. height: 100vh;
  631. }
  632. .sidebar-title {
  633. font-size: 16px;
  634. margin-bottom: 18px;
  635. }
  636. .sidebar-item {
  637. display: flex;
  638. flex-direction: column;
  639. align-items: center;
  640. margin-bottom: 18px;
  641. cursor: pointer;
  642. width: 90%;
  643. }
  644. .sidebar-icon {
  645. width: 34px;
  646. height: 34px;
  647. margin-bottom: 4px;
  648. }
  649. .sidebar-icon.text {
  650. width: 34px;
  651. height: 34px;
  652. background: #fff;
  653. color: #232a36;
  654. display: flex;
  655. align-items: center;
  656. justify-content: center;
  657. font-weight: bold;
  658. font-size: 22px;
  659. border-radius: 6px;
  660. margin-bottom: 4px;
  661. }
  662. .main-editor {
  663. flex: 1;
  664. display: flex;
  665. flex-direction: column;
  666. align-items: center;
  667. justify-content: center;
  668. min-width: 0;
  669. min-height: 0;
  670. position: relative;
  671. }
  672. .editor-canvas {
  673. width: 90%;
  674. height: 75%;
  675. background: #e9eef3;
  676. border-radius: 10px;
  677. display: flex;
  678. align-items: center;
  679. justify-content: center;
  680. margin-bottom: 24px;
  681. box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
  682. position: relative;
  683. overflow: hidden;
  684. }
  685. .toolbar {
  686. display: flex;
  687. flex-direction: row;
  688. align-self: flex-start;
  689. align-items: center;
  690. height: 50px;
  691. width: 700px;
  692. margin-left: 5%;
  693. margin-top: -5%;
  694. margin-bottom: 1%;
  695. background: #fafbfc;
  696. border-radius: 8px;
  697. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  698. gap: 16px;
  699. padding: 0 16px;
  700. }
  701. .toolbar-item {
  702. user-select: none;
  703. cursor: grab;
  704. background: #fff;
  705. border-radius: 4px;
  706. /* margin: 8px 0 8px 8px; */
  707. width: 92px;
  708. min-height: 50px;
  709. white-space: nowrap;
  710. overflow: hidden;
  711. text-overflow: ellipsis;
  712. flex: 1 1 0;
  713. display: flex;
  714. align-items: center;
  715. justify-content: center;
  716. font-size: 24px;
  717. font-weight: 500;
  718. letter-spacing: 1px;
  719. text-align: center;
  720. transition: background 0.2s;
  721. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
  722. /* padding: 0 8px; */
  723. }
  724. .toolbar-item:active {
  725. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.13);
  726. border-color: #409eff;
  727. }
  728. .canvas-content {
  729. display: flex;
  730. flex-direction: column;
  731. align-items: center;
  732. justify-content: center;
  733. }
  734. .canvas-icon {
  735. width: 120px;
  736. height: 120px;
  737. margin-bottom: 18px;
  738. }
  739. .canvas-text {
  740. color: #222;
  741. font-size: 22px;
  742. }
  743. .save-btn {
  744. align-self: flex-end;
  745. margin-right: 8vw;
  746. }
  747. .back-btn {
  748. margin-bottom: 16px;
  749. background: #fff;
  750. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
  751. border: none;
  752. margin-left: auto;
  753. margin-right: auto;
  754. }
  755. .property-info {
  756. margin-bottom: 18px;
  757. }
  758. .property-info-row {
  759. display: flex;
  760. align-items: center;
  761. margin-bottom: 6px;
  762. }
  763. .property-info-label {
  764. color: #888;
  765. min-width: 72px;
  766. font-weight: 500;
  767. }
  768. .property-panel {
  769. width: 260px;
  770. background: #fff;
  771. box-shadow: -2px 0 8px rgba(0, 0, 0, 0.03);
  772. padding: 32px 18px 0 18px;
  773. display: flex;
  774. flex-direction: column;
  775. height: 100vh;
  776. position: relative;
  777. border-left: 1px solid #ececec;
  778. box-sizing: border-box;
  779. justify-content: flex-start;
  780. }
  781. .property-form-area {
  782. flex: 0 0 auto;
  783. }
  784. .property-divider {
  785. height: 1px;
  786. background: #ececec;
  787. margin: 18px 0 12px 0;
  788. width: 100%;
  789. border: none;
  790. }
  791. .el-button+.el-button {
  792. margin-left: 0px;
  793. }
  794. .json-debug-title {
  795. margin-top: 30px;
  796. font-size: 14px;
  797. color: #888;
  798. font-weight: bold;
  799. }
  800. .json-debug {
  801. margin-top: 8px;
  802. font-size: 13px;
  803. background: #f6f8fa;
  804. color: #222;
  805. font-family: 'Fira Mono', 'Consolas', monospace;
  806. }
  807. .property-title {
  808. font-size: 16px;
  809. margin-bottom: 18px;
  810. }
  811. .canvas-default {
  812. width: 600px;
  813. height: 400px;
  814. background: #fff;
  815. margin: 0 auto;
  816. display: flex;
  817. align-items: center;
  818. justify-content: center;
  819. font-size: 20px;
  820. color: #aaa;
  821. border-radius: 12px;
  822. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  823. }
  824. .component-item {
  825. border: 2px solid #e3e3e3;
  826. border-radius: 10px;
  827. background: #fafbfc;
  828. margin-bottom: 16px;
  829. padding: 18px 0;
  830. text-align: center;
  831. cursor: pointer;
  832. transition:
  833. border-color 0.2s,
  834. box-shadow 0.2s;
  835. display: flex;
  836. align-items: center;
  837. justify-content: center;
  838. font-size: 16px;
  839. font-weight: 500;
  840. }
  841. .component-item.selected {
  842. border-color: #409eff;
  843. box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
  844. background: #eaf6ff;
  845. }
  846. .component-icon-text {
  847. display: flex;
  848. align-items: center;
  849. justify-content: center;
  850. width: 100%;
  851. font-size: 18px;
  852. color: #222;
  853. }
  854. .sidebar-item-disabled {
  855. pointer-events: none;
  856. opacity: 0.6;
  857. }
  858. </style>