EditProgram.vue 34 KB

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