CreateCanvas.vue 73 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223
  1. <template>
  2. <main ref="canvas" class="canvas">
  3. <div v-if="isEdit" class="edit">
  4. <!-- 分组线单独放在一个绝对定位容器中,避免与行列表在同一 children 列表里 diff,
  5. 否则删除被分组的行时 Vue 会移动上方行的 DOM,导致 tinymce iframe 重载、内容丢失 -->
  6. <div class="group-lines">
  7. <div
  8. v-for="item in lineList"
  9. :key="item[0]"
  10. class="group-line"
  11. :data-canvas-height="canvasHeight"
  12. :style="computedGroupLine(item)"
  13. ></div>
  14. </div>
  15. <span class="drag-line" data-row="-1"></span>
  16. <!-- 行 -->
  17. <template v-for="(row, i) in data.row_list">
  18. <div :key="`check-${row.row_id}`" class="row" :style="computedRowStyle(i)">
  19. <el-checkbox
  20. v-if="row?.row_id"
  21. v-model="rowCheckList[row.row_id]"
  22. :class="['row-checkbox', `${row.row_id}`]"
  23. />
  24. <!-- 列 -->
  25. <template v-for="(col, j) in row.col_list">
  26. <span
  27. v-if="j === 0"
  28. :key="`start-${row.row_id}-${col.col_id}`"
  29. class="drag-vertical-line col-start"
  30. :data-row="i"
  31. :data-col="j"
  32. ></span>
  33. <div :key="col.col_id" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
  34. <!-- 网格 -->
  35. <template v-for="(grid, k) in col.grid_list">
  36. <span
  37. v-if="k === 0"
  38. :key="`start-${grid.id}`"
  39. class="drag-line grid-line drag-row"
  40. :style="{ gridArea: 'grid-top' }"
  41. :data-row="i"
  42. :data-col="j"
  43. :data-grid="k"
  44. data-type="row"
  45. ></span>
  46. <span
  47. v-if="grid.row > 1 && grid.row !== col.grid_list[k - 1].row"
  48. :key="`middle-${grid.id}`"
  49. :style="{ gridArea: `middle-${grid.grid_area}` }"
  50. :data-row="i"
  51. :data-col="j"
  52. :data-grid="k"
  53. data-type="col-middle"
  54. class="drag-line grid-line"
  55. ></span>
  56. <span
  57. :key="`left-${grid.id}`"
  58. :style="{ gridArea: `left-${grid.grid_area}` }"
  59. :data-row="i"
  60. :data-col="j"
  61. :data-grid="k"
  62. data-type="col-left"
  63. class="drag-vertical-line grid-line grid-line-left"
  64. ></span>
  65. <component
  66. :is="componentList[grid.type]"
  67. :id="grid.id"
  68. ref="component"
  69. :key="`grid-${grid.id}`"
  70. :old-id="grid?.oldId"
  71. :class="['grid', grid.id]"
  72. :data-id="grid.id"
  73. :data-row="i"
  74. :data-col="j"
  75. :data-grid="k"
  76. :data-view-order="computedGridViewOrder(grid.id)"
  77. :border-color="computedBorderColor(row.row_id)"
  78. :style="computedGridStyle(grid, row.row_id)"
  79. :component-move="componentMove(i, j, k)"
  80. @deleteComponent="deleteComponentConfirm"
  81. @showSetting="showSetting"
  82. @copyComponent="copyComponent"
  83. @changeData="changeData"
  84. />
  85. <span
  86. :key="`right-${grid.id}`"
  87. :style="{ gridArea: `right-${grid.grid_area}` }"
  88. :data-row="i"
  89. :data-col="j"
  90. :data-grid="k + 1"
  91. data-type="col-right"
  92. class="drag-vertical-line grid-line grid-line-right"
  93. ></span>
  94. <span
  95. v-if="k === col.grid_list.length - 1"
  96. :key="`end-${grid.id}`"
  97. class="drag-line grid-line drag-row"
  98. :style="{ gridArea: `grid-bottom` }"
  99. :data-row="i"
  100. :data-col="j"
  101. :data-grid="k + 1"
  102. data-type="row"
  103. ></span>
  104. </template>
  105. </div>
  106. <span
  107. :key="`end-${row.row_id}-${col.col_id}`"
  108. class="drag-vertical-line col-end"
  109. :data-row="i"
  110. :data-col="j + 1"
  111. ></span>
  112. </template>
  113. </div>
  114. <span v-if="i < data.row_list.length - 1" :key="`row-${row.row_id}`" class="drag-line" :data-row="i"></span>
  115. </template>
  116. <span class="drag-line" :data-row="data.row_list.length - 1"></span>
  117. </div>
  118. <PreviewEdit
  119. v-else
  120. ref="previewEdit"
  121. :courseware-id="courseware_id"
  122. :row-list="data.row_list"
  123. @computedMoveData="computedMoveData"
  124. @handleHeightChange="handleHeightChange"
  125. @selectedComponent="$emit('selectedComponent', $event)"
  126. />
  127. </main>
  128. </template>
  129. <script>
  130. import { getRandomNumber } from '@/utils/index';
  131. import { componentList } from '../../data/bookType';
  132. import {
  133. ContentSaveCoursewareContent,
  134. ContentGetCoursewareContent,
  135. GetBookUnifiedAttrib,
  136. ContentSaveCoursewareComponentContent,
  137. } from '@/api/book';
  138. import _ from 'lodash';
  139. import { unified_attrib } from '@/common/data';
  140. import { waitLayoutStable } from '@/utils/common';
  141. import PreviewEdit from './PreviewEdit.vue';
  142. export default {
  143. name: 'CreateCanvas',
  144. components: {
  145. PreviewEdit,
  146. },
  147. inject: ['getCurSettingId'],
  148. provide() {
  149. return {
  150. getBookUnifiedAttr: () => this.book_unified_attrib,
  151. getTitleList: () => this.title_list,
  152. getProjectResourcePopedom: () => this.projectResourcePopedom,
  153. moveComponentDragStart: this.dragStart,
  154. getCoursewareId: () => this.courseware_id,
  155. };
  156. },
  157. props: {
  158. isEdit: {
  159. type: Boolean,
  160. required: true,
  161. },
  162. },
  163. data() {
  164. const { project_id } = this.$route.query;
  165. return {
  166. courseware_id: this.$route.params.courseware_id,
  167. project_id,
  168. data: {
  169. // 组件列表
  170. row_list: [],
  171. // 样式调整
  172. unified_attrib,
  173. },
  174. visibleBackground: false, // 背景设置弹窗
  175. rowCheckList: {}, // 行复选框列表
  176. content_group_row_list: [], // 行分组id列表
  177. gridBorderColorList: ['#3fc7cc', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#d863ff', '#724fff'], // 网格边框颜色列表
  178. curType: 'divider',
  179. curParams: {}, // 当前组件参数
  180. scrollInterval: null, // 滚动定时器
  181. componentList,
  182. curRow: -2,
  183. curCol: -1,
  184. curGrid: -1,
  185. gridInsertType: '', // 网格插入类型
  186. enterCanvas: false, // 是否进入画布
  187. // 拖拽状态
  188. drag: {
  189. clientX: 0,
  190. clientY: 0,
  191. dragging: false,
  192. },
  193. visibleFullTextSettings: false,
  194. book_unified_attrib: unified_attrib,
  195. title_list: [],
  196. curComponentId: '', // 当前选中组件 id
  197. projectResourcePopedom: [], // 当前编辑人员的项目资源权限
  198. componentsDataLoaded: false, // 组件数据是否加载完成
  199. visible_id: this.$route.query.visible_id, // 可见组件 id
  200. canvasHeight: 0, // 画布高度,监听变化用于重绘分组线
  201. };
  202. },
  203. computed: {
  204. lineList() {
  205. let arr = [];
  206. this.content_group_row_list.forEach(({ row_id, is_pre_same_group }, i) => {
  207. if (is_pre_same_group) {
  208. arr.push([this.content_group_row_list[i - 1].row_id, row_id]);
  209. }
  210. });
  211. return arr;
  212. },
  213. },
  214. watch: {
  215. drag: {
  216. handler(val) {
  217. if (val.dragging) {
  218. const dragging = document.querySelector('.canvas-dragging');
  219. dragging.style.left = `${val.clientX}px`;
  220. dragging.style.top = `${val.clientY}px`;
  221. }
  222. },
  223. deep: true,
  224. },
  225. enterCanvas: {
  226. handler(val) {
  227. if (val) return;
  228. if (!this.isEdit) return;
  229. const dragLineList = document.querySelectorAll('.drag-line');
  230. dragLineList.forEach((item) => {
  231. item.style.opacity = 0;
  232. });
  233. this.curRow = -2;
  234. const dragVerticalLineList = document.querySelectorAll('.drag-vertical-line');
  235. dragVerticalLineList.forEach((item) => {
  236. item.style.opacity = 0;
  237. });
  238. this.curCol = -1;
  239. this.curGrid = -1;
  240. this.gridInsertType = '';
  241. },
  242. },
  243. 'data.row_list': {
  244. handler(val) {
  245. // row_list 更改时判断是否有当前设置 id 的组件
  246. const curSettingId = this.getCurSettingId();
  247. const find = val.find((row) => {
  248. return row.col_list.find((col) => {
  249. return col.grid_list.find((grid) => grid.id === curSettingId);
  250. });
  251. });
  252. if (!find) {
  253. this.$emit('showSettingEmpty');
  254. }
  255. this.rowCheckList = Object.fromEntries(val.filter((row) => row?.row_id).map((row) => [row.row_id, false]));
  256. // 增加新添的行
  257. val.forEach(({ row_id }, i) => {
  258. let isHas = this.content_group_row_list.some((group) => group.row_id === row_id);
  259. if (!isHas) {
  260. this.content_group_row_list.splice(i, 0, { row_id, is_pre_same_group: false });
  261. [i - 1, i + 1].forEach((start) => {
  262. let step = start < i ? -1 : 1;
  263. for (let j = start; j >= 0 && j < this.content_group_row_list.length; j += step) {
  264. if (this.content_group_row_list[j].is_pre_same_group) {
  265. this.content_group_row_list[j].is_pre_same_group = false;
  266. } else {
  267. break;
  268. }
  269. }
  270. });
  271. }
  272. });
  273. // 过滤掉已经不存在的行,并修正行删除后的分组状态
  274. this.content_group_row_list = this.normalizeGroupRowList(this.content_group_row_list, val);
  275. },
  276. immediate: true,
  277. },
  278. rowCheckList: {
  279. handler(val) {
  280. // 如果同时有两个选中,将选中的挑选出来,并将它们的状态变为 false
  281. let selectedRowID = [];
  282. Object.keys(val).forEach((key) => {
  283. if (val[key]) {
  284. selectedRowID.push(key);
  285. }
  286. });
  287. if (selectedRowID.length > 1) {
  288. selectedRowID.forEach((id) => {
  289. this.rowCheckList[id] = false;
  290. });
  291. } else {
  292. return false;
  293. }
  294. let selectIndex = selectedRowID
  295. .map((id) => this.content_group_row_list.findIndex(({ row_id }) => row_id === id))
  296. .sort((a, b) => a - b);
  297. // 只处理选中两行的情况
  298. if (selectIndex[1] - selectIndex[0] === 1) {
  299. // 相邻两行,切换分组状态
  300. this.content_group_row_list[selectIndex[1]].is_pre_same_group =
  301. !this.content_group_row_list[selectIndex[1]].is_pre_same_group;
  302. } else {
  303. // 非相邻,判断中间行是否都已分组
  304. const allGrouped = this.content_group_row_list
  305. .slice(selectIndex[0] + 1, selectIndex[1] + 1)
  306. .every((group) => group.is_pre_same_group);
  307. for (let i = selectIndex[0] + 1; i <= selectIndex[1]; i++) {
  308. this.content_group_row_list[i].is_pre_same_group = !allGrouped;
  309. }
  310. }
  311. },
  312. deep: true,
  313. },
  314. componentsDataLoaded: {
  315. async handler(val) {
  316. if (val && this.visible_id) {
  317. await this.$nextTick();
  318. let isAllLoader = false;
  319. if (this.$refs?.component === undefined || this.$refs?.component.length === 0) {
  320. isAllLoader = true;
  321. } else {
  322. isAllLoader = this.$refs?.component?.every((item) => item.property.isGetContent);
  323. }
  324. if (isAllLoader) {
  325. this.visibleIdScrollIntoView();
  326. } else {
  327. let checkInterval = setInterval(() => {
  328. let isAllLoader = this.$refs?.component?.every((item) => item.property.isGetContent);
  329. if (isAllLoader) {
  330. clearInterval(checkInterval);
  331. this.visibleIdScrollIntoView();
  332. }
  333. }, 100);
  334. }
  335. }
  336. },
  337. },
  338. isEdit: {
  339. handler(val) {
  340. if (val) {
  341. const loading = this.$loading({
  342. lock: true,
  343. text: '组件加载中...',
  344. spinner: 'el-icon-loading',
  345. });
  346. const timer = setInterval(() => {
  347. if (this.componentIsAllLoader()) {
  348. loading.close();
  349. clearInterval(timer);
  350. }
  351. }, 200);
  352. }
  353. },
  354. },
  355. },
  356. created() {
  357. const loading = this.$loading({
  358. lock: true,
  359. text: '组件加载中...',
  360. spinner: 'el-icon-loading',
  361. });
  362. ContentGetCoursewareContent({ id: this.courseware_id }).then(
  363. ({ content, content_group_row_list, title_list, my_project_resource_popedom }) => {
  364. const groupRowList = content_group_row_list ? JSON.parse(content_group_row_list) : [];
  365. if (content) {
  366. let parsedContent = JSON.parse(content);
  367. if (parsedContent && Array.isArray(parsedContent.row_list)) {
  368. parsedContent = this.normalizeRowColIds(parsedContent);
  369. const normalized = this.normalizeCanvasRows(parsedContent.row_list, groupRowList);
  370. parsedContent.row_list = normalized.rowList;
  371. this.content_group_row_list = normalized.groupRowList;
  372. }
  373. this.data = parsedContent;
  374. } else {
  375. this.content_group_row_list = [];
  376. }
  377. const timer = setInterval(() => {
  378. if (this.componentIsAllLoader()) {
  379. loading.close();
  380. clearInterval(timer);
  381. this.componentsDataLoaded = true;
  382. }
  383. }, 200);
  384. if (title_list) this.title_list = title_list || [];
  385. this.$watch(
  386. 'data',
  387. () => {
  388. this.changeData();
  389. },
  390. {
  391. deep: true,
  392. },
  393. );
  394. this.projectResourcePopedom = my_project_resource_popedom || [];
  395. },
  396. );
  397. this.getBookUnifiedAttr();
  398. },
  399. mounted() {
  400. document.addEventListener('mousemove', this.dragMove);
  401. document.addEventListener('mouseup', this.dragEnd);
  402. window.addEventListener('keydown', this.handleCopy);
  403. // 监听画布高度变化,重绘分组线
  404. this.initCanvasResizeObserver();
  405. },
  406. beforeDestroy() {
  407. document.removeEventListener('mousemove', this.dragMove);
  408. document.removeEventListener('mouseup', this.dragEnd);
  409. window.removeEventListener('keydown', this.handleCopy);
  410. this.destroyCanvasResizeObserver();
  411. },
  412. methods: {
  413. /**
  414. * 判断所有组件是否加载完成
  415. * @returns {boolean} 是否所有组件加载完成
  416. */
  417. componentIsAllLoader() {
  418. const toList = (ref) => {
  419. if (Array.isArray(ref)) return ref;
  420. return ref ? [ref] : [];
  421. };
  422. if (this.isEdit) {
  423. const components = toList(this.$refs?.component);
  424. return components.length === 0 || components.every((item) => item?.property?.isGetContent);
  425. }
  426. const previews = toList(this.$refs?.previewEdit?.$refs?.preview);
  427. return previews.length === 0 || previews.every((item) => item?.loader);
  428. },
  429. /**
  430. * 将可见组件滚动到视图中心
  431. */
  432. async visibleIdScrollIntoView() {
  433. await this.$nextTick();
  434. const componentNum = this.$refs?.component?.length || 0;
  435. const frames = Math.ceil(componentNum / 10); // 每10个组件为一帧,计算需要等待的帧数
  436. await waitLayoutStable(frames);
  437. const target = this.findChildComponentByKey(`grid-${this.visible_id}`);
  438. const targetElement = target?.$el || target;
  439. if (targetElement && typeof targetElement.scrollIntoView === 'function') {
  440. targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
  441. }
  442. },
  443. /**
  444. * 监听复制事件,触发复制组件方法
  445. * @param {KeyboardEvent} event 键盘事件
  446. */
  447. handleCopy(event) {
  448. if (!event || event.isComposing || event.repeat) return;
  449. // 输入态下不拦截,避免影响用户正常输入
  450. const activeElement = document.activeElement;
  451. const isInputting =
  452. activeElement &&
  453. (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable);
  454. if (isInputting) return;
  455. if (!this.curComponentId) return;
  456. const isCtrlC =
  457. event.ctrlKey &&
  458. !event.altKey &&
  459. !event.metaKey &&
  460. !event.shiftKey &&
  461. (event.code === 'KeyC' || String(event.key).toLowerCase() === 'c');
  462. if (isCtrlC) {
  463. event.preventDefault();
  464. this.findChildComponentByKey(`grid-${this.curComponentId}`)?.copyComponent();
  465. }
  466. },
  467. handleHeightChange(id, newHeight) {
  468. this.data.row_list.forEach((row) => {
  469. row.col_list.forEach((col) => {
  470. col.grid_list.forEach((grid) => {
  471. if (grid.id === id) {
  472. grid.height = newHeight;
  473. }
  474. });
  475. });
  476. });
  477. },
  478. resetPreviewHeight(componentId) {
  479. if (this.isEdit || !componentId) return false;
  480. for (const row of this.data.row_list) {
  481. for (const col of row.col_list) {
  482. const grid = col.grid_list.find((item) => item.id === componentId);
  483. if (grid) {
  484. grid.height = 'auto';
  485. return true;
  486. }
  487. }
  488. }
  489. return false;
  490. },
  491. changeData() {
  492. this.$emit('changeData');
  493. },
  494. /**
  495. * 应用全文设置
  496. * @param {object} data 全文设置数据
  497. */
  498. fullTextSettings(data) {
  499. this.$refs.component.forEach((item) => {
  500. item.updateProperty('view_pinyin', data.view_pinyin);
  501. item.updateProperty('pinyin_position', data.pinyin_position);
  502. const formats = [
  503. { type: 'fontFamily', val: data.font },
  504. { type: 'fontSize', val: data.font_size },
  505. { type: 'lineHeight', val: data.line_height },
  506. { type: 'color', val: data.text_color },
  507. { type: 'align', val: data.align },
  508. ];
  509. item.patchSetRichFormat(formats);
  510. item.setUnifiedAttr(data);
  511. });
  512. this.data.unified_attrib = data;
  513. },
  514. /**
  515. * 应用属性到选中组件
  516. * @param {object} data 属性数据
  517. */
  518. applyToSelectedComponents(data) {
  519. this.$refs.component.forEach((item) => {
  520. if (item.$refs.base.checked) {
  521. item.updateProperty('view_pinyin', data.view_pinyin);
  522. item.updateProperty('pinyin_position', data.pinyin_position);
  523. const formats = [
  524. { type: 'fontFamily', val: data.font },
  525. { type: 'fontSize', val: data.font_size },
  526. { type: 'lineHeight', val: data.line_height },
  527. { type: 'color', val: data.text_color },
  528. { type: 'align', val: data.align },
  529. ];
  530. item.patchSetRichFormat(formats);
  531. item.setUnifiedAttr(data);
  532. }
  533. });
  534. },
  535. /**
  536. * 应用单个属性到选中组件
  537. * @param {string} attr 属性名称
  538. * @param {any} val 属性值
  539. */
  540. applySingleAttrToSelectedComponents(attr, val) {
  541. this.$refs.component.forEach((item) => {
  542. if (item.$refs.base.checked) {
  543. switch (attr) {
  544. case 'view_pinyin':
  545. case 'pinyin_position':
  546. item.updateProperty(attr, val);
  547. break;
  548. case 'font':
  549. item.updateRichTextProperty('fontFamily', val);
  550. break;
  551. case 'font_size':
  552. item.updateRichTextProperty('fontSize', val);
  553. break;
  554. case 'line_height':
  555. item.updateRichTextProperty('lineHeight', val);
  556. break;
  557. case 'text_color':
  558. item.updateRichTextProperty('color', val);
  559. break;
  560. case 'align':
  561. item.updateRichTextProperty('align', val);
  562. break;
  563. default:
  564. break;
  565. }
  566. item.setSingleUnifiedAttr(attr, val);
  567. }
  568. });
  569. },
  570. /**
  571. * 应用单个属性到所有组件
  572. * @param {String} attr - 属性名
  573. * @param {any} val - 属性值
  574. */
  575. applySingleAttrToAllComponents(attr, val) {
  576. this.$refs.component.forEach((item) => {
  577. switch (attr) {
  578. case 'view_pinyin':
  579. case 'pinyin_position':
  580. item.updateProperty(attr, val);
  581. break;
  582. case 'font':
  583. item.updateRichTextProperty('fontFamily', val);
  584. break;
  585. case 'font_size':
  586. item.updateRichTextProperty('fontSize', val);
  587. break;
  588. case 'line_height':
  589. item.updateRichTextProperty('lineHeight', val);
  590. break;
  591. case 'text_color':
  592. item.updateRichTextProperty('color', val);
  593. break;
  594. case 'align':
  595. item.updateRichTextProperty('align', val);
  596. break;
  597. default:
  598. break;
  599. }
  600. item.setSingleUnifiedAttr(attr, val);
  601. });
  602. },
  603. getBookUnifiedAttr() {
  604. GetBookUnifiedAttrib({ book_id: this.project_id }).then(({ content }) => {
  605. if (content) {
  606. this.book_unified_attrib = JSON.parse(content);
  607. }
  608. });
  609. },
  610. /**
  611. * 保存课件内容
  612. * @param {string} type 类型
  613. */
  614. async saveCoursewareContent(type) {
  615. if (!this.componentIsAllLoader()) {
  616. this.$message.warning('有组件内容未加载完成,请稍后再试');
  617. return false;
  618. }
  619. const loading = this.$loading({
  620. lock: true,
  621. text: '保存中...',
  622. spinner: 'el-icon-loading',
  623. background: 'rgba(0, 0, 0, 0.7)',
  624. });
  625. try {
  626. // 先等待所有子组件内容保存完成
  627. if (this.isEdit) {
  628. const comps = Array.isArray(this.$refs?.component)
  629. ? this.$refs.component
  630. : [this.$refs?.component].filter(Boolean);
  631. await Promise.all(comps.map((item) => item.saveCoursewareComponentContent()));
  632. }
  633. // 组件内容已按组件自身 id 保存到当前课件,清理仅用于从模板课件取内容的临时标记 oldId,
  634. // 否则该课件之后再次被当作模板使用时组件会按陈旧的 oldId 请求而取到空内容
  635. this.clearGridOldId();
  636. // 再收集并保存页面结构
  637. let component_id_list = this.data.row_list.flatMap((row) =>
  638. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  639. );
  640. let groupIdList = _.cloneDeep(this.content_group_row_list);
  641. let groupList = [];
  642. // 通过判断 is_pre_same_group 将组合并
  643. for (let i = 0; i < groupIdList.length; i++) {
  644. if (groupIdList[i].is_pre_same_group) {
  645. groupList[groupList.length - 1].row_id_list.push(groupIdList[i].row_id);
  646. } else {
  647. groupList.push({
  648. name: '',
  649. row_id_list: [groupIdList[i].row_id],
  650. component_id_list: [],
  651. });
  652. }
  653. }
  654. // 通过合并后的分组,获取对应的组件 id 和分组名称
  655. groupList.forEach(({ row_id_list, component_id_list }, i) => {
  656. row_id_list.forEach((row_id, j) => {
  657. let row = this.data.row_list.find((row) => {
  658. return row.row_id === row_id;
  659. });
  660. // 当前行所有组件id列表
  661. let gridIdList = row.col_list.map((col) => col.grid_list.map((grid) => grid.id)).flat();
  662. component_id_list.push(...gridIdList);
  663. // 查找每组第一行中第一个包含 describe、label 或 stem 的组件
  664. if (j === 0) {
  665. let findKey = '';
  666. let findType = '';
  667. row.col_list.some((col) => {
  668. const findItem = col.grid_list.find(({ type }) => {
  669. return ['describe', 'label', 'stem'].includes(type);
  670. });
  671. if (findItem) {
  672. findKey = findItem.id;
  673. findType = findItem.type;
  674. return true;
  675. }
  676. });
  677. let groupName = `组${i + 1}`;
  678. // 如果有标签类组件,获取对应名称
  679. if (findKey) {
  680. let item = this.isEdit
  681. ? this.findChildComponentByKey(`grid-${findKey}`)
  682. : this.$refs.previewEdit.findChildComponentByKey(`preview-${findKey}`);
  683. if (['describe', 'stem'].includes(findType)) {
  684. groupName = item.data.content.replace(/<[^>]+>/g, '');
  685. } else if (findType === 'label') {
  686. groupName = item.data.dynamicTags.map((tag) => tag.text).join(', ');
  687. }
  688. }
  689. groupList[i].name = groupName;
  690. }
  691. });
  692. });
  693. await ContentSaveCoursewareContent({
  694. id: this.courseware_id,
  695. category: 'NEW',
  696. content: JSON.stringify(this.data),
  697. component_id_list,
  698. content_group_component_list: groupList,
  699. content_group_row_list: this.content_group_row_list,
  700. });
  701. this.$message.success('保存成功');
  702. if (type === 'quit') {
  703. this.$emit('back', this.getFirstVisibleComponentId());
  704. }
  705. if (type === 'edit') {
  706. this.$emit('changeEditStatus');
  707. }
  708. if (type === 'back') {
  709. this.$router.push({
  710. path: '/personal_workbench/edit_task',
  711. });
  712. }
  713. } finally {
  714. loading.close();
  715. }
  716. },
  717. /**
  718. * 获取 create-middle 视口内第一个可见的组件 id
  719. * @returns {string} 第一个可见组件 id;不存在时返回空字符串
  720. */
  721. getFirstVisibleComponentId() {
  722. const container = document.querySelector('.create-middle');
  723. if (!container) return '';
  724. const containerRect = container.getBoundingClientRect();
  725. const gridElements = container.querySelectorAll('.canvas .grid[data-id]');
  726. let firstVisibleId = '';
  727. let minTop = Number.POSITIVE_INFINITY; // 初始化为正无穷大,以确保任何可见元素的 top 都会小于它
  728. gridElements.forEach((el) => {
  729. const rect = el.getBoundingClientRect();
  730. const visibleHeight = Math.min(rect.bottom, containerRect.bottom) - Math.max(rect.top, containerRect.top);
  731. const isVisible = visibleHeight > 0;
  732. if (isVisible && rect.top < minTop) {
  733. minTop = rect.top;
  734. firstVisibleId = el.dataset.id || '';
  735. }
  736. });
  737. return firstVisibleId;
  738. },
  739. /**
  740. * 显示设置
  741. * @param {object} setting 组件设置数据
  742. * @param {string} type 组件类型
  743. * @param {string} id 组件 id
  744. * @param {object} params 组件参数
  745. */
  746. showSetting(setting, type, id, params = {}) {
  747. this.curComponentId = id;
  748. this.$emit('showSetting', setting, type, id, params);
  749. },
  750. /**
  751. * 组件中添加答案或解析
  752. * @param {'answer'|'analysis'} type 类型
  753. */
  754. addAnswerAndAnalysis(type) {
  755. this.findChildComponentByKey(`grid-${this.curComponentId}`)?.addAnswerAndAnalysis(type);
  756. },
  757. /**
  758. * 计算组件移动
  759. * @param {number} i 行
  760. * @param {number} j 列
  761. * @param {number} k 格子
  762. */
  763. componentMove(i, j, k) {
  764. return ({ type, offsetX, offsetY, id }) => {
  765. this.computedMoveData({ i, j, k, type, offsetX, offsetY, id });
  766. };
  767. },
  768. /**
  769. * 计算移动数据
  770. * @param {number} i 行
  771. * @param {number} j 列
  772. * @param {number} k 格子
  773. * @param {string} type 移动类型
  774. * @param {number} offsetX x 轴偏移量
  775. * @param {number} offsetY y 轴偏移量
  776. * @param {string} id 组件 id
  777. */
  778. computedMoveData({ i, j, k, type, offsetX, offsetY, id, min_width, min_height, row_width }) {
  779. const row = this.data.row_list[i];
  780. const col = row.col_list[j];
  781. const grid = col.grid_list[k];
  782. // 上下移动
  783. if (['top', 'bottom'].includes(type)) {
  784. this.handleVerticalMove({ grid, offsetY, id, min_height });
  785. return;
  786. }
  787. // 一行中有多个格子
  788. let gridList = col.grid_list.filter((item) => item.row === grid.row);
  789. if (gridList.length > 1) {
  790. let find = gridList.findIndex((item) => item.id === id); // 当前 id 所在格子索引
  791. // 移动类型为 left 且不是第一个格子
  792. if (type === 'left' && find > 0) {
  793. this.handleMultGridLeftMoveNotFirstGrid({ gridList, grid, col, find, k, offsetX, min_width, row_width });
  794. }
  795. // 移动类型为 right 且不是最后一个格子
  796. if (type === 'right' && find < gridList.length - 1) {
  797. this.handleMultGridRightMoveNotFirstGrid({ gridList, grid, col, find, k, offsetX, min_width, row_width });
  798. }
  799. return;
  800. }
  801. // 移动类型为 left 且不是第一个格子
  802. if (type === 'left' && j > 0) {
  803. this.handleLeftMoveNotFirstGrid(row, j, offsetX, min_width, row_width);
  804. }
  805. // 移动类型为 right 且不是最后一个格子
  806. if (type === 'right' && j < row.col_list.length - 1) {
  807. this.handleRightMoveNotLastGrid(row, j, offsetX, min_width, row_width);
  808. }
  809. },
  810. /**
  811. * 处理垂直移动
  812. * @param {object} data 移动数据
  813. * @param {object} data.grid 格子数据
  814. * @param {number} data.offsetY y 轴偏移量
  815. * @param {string} data.id 组件 id
  816. * @param {number} data.min_height 最小高度
  817. */
  818. handleVerticalMove({ grid, offsetY, id, min_height = 0 }) {
  819. let height = 0;
  820. const _h = this.isEdit ? grid?.edit_height : grid.height;
  821. // 高度为 auto 时
  822. if (_h === 'auto' || _h === undefined) {
  823. const gridHeight = document.querySelector(`.${id}`).offsetHeight;
  824. height = gridHeight + offsetY;
  825. } else {
  826. // 高度为数字时
  827. const h = this.isEdit ? grid?.edit_height : grid.height;
  828. const gridHeight = Number(h?.replace('px', ''));
  829. height = gridHeight + offsetY;
  830. }
  831. // 分隔线/间距等纯装饰组件自身没有 min_height,兜底为 10px,防止被拖拽到 0 而消失;
  832. // 其余组件使用各自数据中定义的 min_height(未定义时保持原行为:可折叠到 0)
  833. const thinComponentTypeList = ['divider', 'spacing'];
  834. let minHeight = thinComponentTypeList.includes(grid?.type) ? 10 : min_height;
  835. // 当高度小于最小高度时,设置为最小高度
  836. height = Math.max(height, min_height, minHeight);
  837. if (this.isEdit) {
  838. grid.edit_height = `${height}px`;
  839. } else {
  840. grid.height = `${height}px`;
  841. }
  842. },
  843. /**
  844. * 处理左移且不是第一个格子
  845. * @param {object} row 行数据
  846. * @param {number} j 第几列
  847. * @param {number} offsetX x 轴偏移量
  848. * @param {number} min_width 最小宽度
  849. * @param {number} row_width 行宽度
  850. */
  851. handleLeftMoveNotFirstGrid(row, j, offsetX, min_width, row_width) {
  852. const prevGrid = row.width_list[j - 1];
  853. const prevWidth = Number(prevGrid.replace('fr', ''));
  854. const width = Number(row.width_list[j].replace('fr', ''));
  855. const max = prevWidth + width - 10;
  856. // 当 min_width 不为 1 且拖动后宽度小于 10 或大于最大宽度时,直接返回
  857. if (
  858. min_width !== 1 &&
  859. (prevWidth + offsetX < 10 || prevWidth + offsetX > max || width - offsetX > max || width - offsetX < 10)
  860. ) {
  861. return;
  862. }
  863. // 计算拖动的距离与总宽度的比例
  864. const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
  865. const _w = width - ratio;
  866. if ((_w / 100) * row_width < min_width) {
  867. return;
  868. }
  869. row.width_list[j - 1] = `${prevWidth + ratio}fr`;
  870. row.width_list[j] = `${width - ratio}fr`;
  871. },
  872. /**
  873. * 处理一行中有多个格子的左移且不是第一个格子
  874. * @param {object} gridList 格子列表
  875. * @param {object} grid 格子数据
  876. * @param {object} col 列数据
  877. * @param {number} find 当前 id 所在格子索引
  878. * @param {number} k 格子的索引
  879. * @param {number} offsetX x 轴偏移量
  880. * @param {number} min_width 最小宽度
  881. */
  882. handleMultGridLeftMoveNotFirstGrid({ gridList, grid, col, find, k, offsetX, min_width, row_width }) {
  883. const prevGrid = gridList[find - 1];
  884. const prevWidth = Number(prevGrid.width.replace('fr', ''));
  885. const width = Number(grid.width.replace('fr', ''));
  886. const max = prevWidth + width - 10;
  887. if (
  888. min_width !== 1 &&
  889. (prevWidth + offsetX < 10 || prevWidth + offsetX > max || width - offsetX > max || width - offsetX < 10)
  890. ) {
  891. return;
  892. }
  893. // 计算拖动的距离与总宽度的比例
  894. const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
  895. const _w = width - ratio;
  896. if ((_w / 100) * row_width < min_width) {
  897. return;
  898. }
  899. col.grid_list[k - 1].width = `${prevWidth + ratio}fr`;
  900. grid.width = `${_w}fr`;
  901. },
  902. /**
  903. * 处理右移且不是最后一个格子
  904. * @param {object} row 行数据
  905. * @param {number} j 第几列
  906. * @param {number} offsetX x 轴偏移量
  907. * @param {number} min_width 最小宽度
  908. * @param {number} row_width 行宽度
  909. */
  910. handleRightMoveNotLastGrid(row, j, offsetX, min_width, row_width) {
  911. let nextGrid = row.width_list[j + 1];
  912. const nextWidth = Number(nextGrid.replace('fr', ''));
  913. const width = Number(row.width_list[j].replace('fr', ''));
  914. const max = nextWidth + width - 10;
  915. if (
  916. min_width !== 1 &&
  917. (nextWidth - offsetX < 10 || nextWidth - offsetX > max || width + offsetX > max || width + offsetX < 10)
  918. ) {
  919. return;
  920. }
  921. const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
  922. const _w = width + ratio;
  923. if ((_w / 100) * row_width < min_width) {
  924. return;
  925. }
  926. row.width_list[j + 1] = `${nextWidth - ratio}fr`;
  927. row.width_list[j] = `${width + ratio}fr`;
  928. },
  929. /**
  930. * 处理一行中有多个格子的右移且不是最后一个格子
  931. * @param {object} gridList 格子列表
  932. * @param {object} grid 格子数据
  933. * @param {object} col 列数据
  934. * @param {number} find 当前 id 所在格子索引
  935. * @param {number} k 格子的索引
  936. * @param {number} offsetX x 轴偏移量
  937. * @param {number} min_width 最小宽度
  938. */
  939. handleMultGridRightMoveNotFirstGrid({ gridList, grid, col, find, k, offsetX, min_width, row_width }) {
  940. let nextGrid = gridList[find + 1];
  941. const nextWidth = Number(nextGrid.width.replace('fr', ''));
  942. const width = Number(grid.width.replace('fr', ''));
  943. const max = nextWidth + width - 10;
  944. if (
  945. min_width !== 1 &&
  946. (nextWidth - offsetX < 10 || nextWidth - offsetX > max || width + offsetX > max || width + offsetX < 10)
  947. ) {
  948. return;
  949. }
  950. const ratio = (offsetX / document.querySelector('.row').offsetWidth) * 100;
  951. const _w = width + ratio;
  952. if ((_w / 100) * row_width < min_width) {
  953. return;
  954. }
  955. col.grid_list[k + 1].width = `${nextWidth - ratio}fr`;
  956. grid.width = `${width + ratio}fr`;
  957. },
  958. /**
  959. * 重新计算格子宽度
  960. * @param {number} i 行
  961. * @param {number} j 列
  962. */
  963. recalculateGridWidth(i, j) {
  964. let col = this.data.row_list[i].col_list[j];
  965. let grid_template_columns = '0';
  966. col.grid_list.forEach(({ width }, i) => {
  967. const w = `${width}`;
  968. if (i === col.grid_list.length - 1) {
  969. grid_template_columns += ` ${w} 0`;
  970. } else {
  971. grid_template_columns += ` ${w} `;
  972. }
  973. });
  974. col.grid_template_columns = grid_template_columns;
  975. },
  976. async deleteComponentConfirm(id) {
  977. try {
  978. await this.$confirm('确定要删除该组件吗?', '提示', {
  979. confirmButtonText: '确定',
  980. cancelButtonText: '取消',
  981. type: 'warning',
  982. });
  983. this.deleteComponent(id);
  984. } catch (err) {
  985. if (err === 'cancel') {
  986. this.$message.info('已取消删除');
  987. }
  988. }
  989. },
  990. /**
  991. * 删除组件
  992. * @param {String} id 组件 id
  993. */
  994. deleteComponent(id) {
  995. const attrs = this.findChildComponentByKey(`grid-${id}`)?.$attrs;
  996. if (!attrs) return;
  997. const i = Number(attrs['data-row']);
  998. const j = Number(attrs['data-col']);
  999. const k = Number(attrs['data-grid']);
  1000. const gridList = this.data.row_list[i].col_list[j].grid_list;
  1001. let delRow = gridList[k].row; // 删除的 grid 的 row
  1002. let delW = gridList[k].width; // 删除的 grid 的 width
  1003. gridList.splice(k, 1);
  1004. // 如果删除后没有 grid 了则删除列
  1005. const colList = this.data.row_list[i].col_list[j];
  1006. if (colList.grid_list.length === 0) {
  1007. this.data.row_list[i].col_list.splice(j, 1);
  1008. let width_list = this.data.row_list[i].width_list;
  1009. const delW = width_list[j];
  1010. width_list.splice(j, 1);
  1011. this.data.row_list[i].width_list = width_list.map((item) => {
  1012. return `${Number(item.replace('fr', '')) + Number(delW.replace('fr', '') / width_list.length)}fr`;
  1013. });
  1014. }
  1015. // 如果删除后没有列了则删除行
  1016. if (this.data.row_list[i].col_list.length === 0) {
  1017. this.data.row_list.splice(i, 1);
  1018. }
  1019. // 如果删除后还有 grid 则重新计算 grid 的 row 和 width
  1020. if (gridList?.length > 0) {
  1021. let delNum = gridList.filter(({ row }) => row === delRow).length;
  1022. let diff = Number(delW.replace('fr', '')) / delNum;
  1023. if (delNum === 0) {
  1024. // 删除 grid 后面的 row 都减 1
  1025. gridList.forEach((item) => {
  1026. if (item.row > delRow) {
  1027. item.row -= 1;
  1028. }
  1029. });
  1030. } else {
  1031. gridList.forEach((item) => {
  1032. if (item.row === delRow) {
  1033. item.width = `${Number(item.width.replace('fr', '')) + diff}fr`;
  1034. }
  1035. });
  1036. }
  1037. }
  1038. },
  1039. computedColStyle(col) {
  1040. const grid = col.grid_list || [];
  1041. // 只分组一次,后续复用,避免在循环中反复 filter/find。
  1042. const rowMap = new Map();
  1043. const rowOrder = [];
  1044. let maxCol = 0;
  1045. let curMaxRow = 0;
  1046. grid.forEach((item) => {
  1047. if (!rowMap.has(item.row)) {
  1048. rowMap.set(item.row, []);
  1049. rowOrder.push(item.row);
  1050. }
  1051. const rowItems = rowMap.get(item.row);
  1052. rowItems.push(item);
  1053. if (rowItems.length > maxCol) {
  1054. maxCol = rowItems.length;
  1055. curMaxRow = item.row;
  1056. }
  1057. });
  1058. // 计算 grid_template_areas
  1059. const areaLines = [];
  1060. rowOrder.forEach((row, index) => {
  1061. const rowItems = rowMap.get(row) || [];
  1062. if (index > 0 && row > 1) {
  1063. areaLines.push(`'${`middle-${rowItems[0].grid_area} `.repeat(maxCol * 3)}'`);
  1064. }
  1065. const rowAreas = [];
  1066. if (row === curMaxRow) {
  1067. rowItems.forEach(({ grid_area }) => {
  1068. rowAreas.push(`left-${grid_area} ${grid_area} right-${grid_area}`);
  1069. });
  1070. } else {
  1071. const needNum = (maxCol - rowItems.length) * 3;
  1072. const spread = rowItems.length > 1 ? this.splitInteger(needNum, rowItems.length) : [];
  1073. rowItems.forEach(({ grid_area }, i) => {
  1074. const repeatNum = rowItems.length === 1 ? needNum + 1 : spread[i] + 1;
  1075. const inner = ` ${grid_area} `.repeat(repeatNum);
  1076. rowAreas.push(`left-${grid_area} ${inner} right-${grid_area}`);
  1077. });
  1078. }
  1079. areaLines.push(`'${rowAreas.join(' ')}'`);
  1080. });
  1081. // 计算 grid_template_columns(使用列数最多的一行作为基准)
  1082. const maxRowItems = rowMap.get(curMaxRow) || [];
  1083. const gridTemCols = maxRowItems.map(({ width }) => width).join(' 4px 4px ');
  1084. // 计算 grid_template_rows
  1085. const rowHeights = rowOrder.map((row) => {
  1086. const heights = (rowMap.get(row) || []).map((item) => item?.edit_height || item.height);
  1087. if (heights.length <= 1) return heights[0] || 'auto';
  1088. const isAllAuto = heights.every((item) => item === 'auto');
  1089. return isAllAuto ? 'auto' : `max(${heights.join(', ')})`;
  1090. });
  1091. return {
  1092. width: col.width,
  1093. gridTemplateAreas: `'${'grid-top '.repeat(maxCol * 3)}' ${areaLines.join(' ')} '${'grid-bottom '.repeat(maxCol * 3)}'`,
  1094. gridTemplateColumns: `0 ${gridTemCols} 0`,
  1095. gridTemplateRows: `0 ${rowHeights.join(' 4px ')} 0`,
  1096. };
  1097. },
  1098. /**
  1099. * 分割整数为多个 3 的倍数
  1100. * @param {number} num
  1101. * @param {number} parts
  1102. */
  1103. splitInteger(num, parts) {
  1104. let base = Math.floor(num / parts / 3) * 3;
  1105. let arr = Array(parts).fill(base);
  1106. let remainder = num - base * parts;
  1107. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  1108. arr[i] += 3;
  1109. remainder -= 3;
  1110. }
  1111. return arr;
  1112. },
  1113. /**
  1114. * 拖拽开始
  1115. * 用点击模拟拖拽
  1116. * @param {MouseEvent} event 鼠标事件
  1117. * @param {string} type 组件类型
  1118. * @param {object} params 组件参数
  1119. */
  1120. dragStart(event, type, params = {}) {
  1121. // 获取鼠标位置
  1122. const { clientX, clientY } = event;
  1123. document.body.style.userSelect = 'none'; // 禁止选中文本
  1124. this.drag.dragging = true;
  1125. this.curType = type;
  1126. this.curParams = params;
  1127. // 在鼠标位置创建一个拖拽元素
  1128. const dragging = document.createElement('div');
  1129. dragging.className = `canvas-dragging${type === 'component' ? ' component-dragging' : ''}`;
  1130. this.drag.clientX = clientX;
  1131. this.drag.clientY = clientY;
  1132. document.body.appendChild(dragging);
  1133. },
  1134. /**
  1135. * 鼠标移动
  1136. */
  1137. dragMove(event) {
  1138. if (!this.drag.dragging) return;
  1139. const { clientX, clientY } = event;
  1140. this.drag.clientX = clientX;
  1141. this.drag.clientY = clientY;
  1142. if (!this.isEdit) return; // 非编辑状态不允许显隐线
  1143. let { isInsideCanvas } = this.getMarginDifferences();
  1144. this.enterCanvas = isInsideCanvas;
  1145. if (!isInsideCanvas) return;
  1146. // 当移动组件时,如果鼠标位置距离画布上下边距小于 20px,则自动滚动画布,做一个定时器每隔 100ms 判断一次,直到鼠标位置距离边距大于 20px 或者离开画布
  1147. if (this.curType === 'component') {
  1148. const middleDom = document.querySelector('.create-middle');
  1149. // 获取 middleDom 相对于 event 的位置
  1150. const middleRect = middleDom.getBoundingClientRect();
  1151. const middleHeight = middleRect.height;
  1152. const offsetTop = clientY - middleRect.top;
  1153. const offsetBottom = middleRect.bottom - clientY;
  1154. if (this.scrollInterval) {
  1155. clearInterval(this.scrollInterval);
  1156. }
  1157. if (offsetTop < 20) {
  1158. this.scrollInterval = setInterval(() => {
  1159. middleDom.scrollTop = Math.max(0, middleDom.scrollTop - 10);
  1160. }, 10);
  1161. } else if (offsetBottom < 20) {
  1162. this.scrollInterval = setInterval(() => {
  1163. middleDom.scrollTop = Math.min(middleDom.scrollHeight - middleHeight, middleDom.scrollTop + 10);
  1164. }, 10);
  1165. } else {
  1166. clearInterval(this.scrollInterval);
  1167. this.scrollInterval = null;
  1168. }
  1169. }
  1170. this.showRecentLine(clientX, clientY);
  1171. },
  1172. /**
  1173. * 显示最近的线
  1174. * @param {number} clientX
  1175. * @param {number} clientY
  1176. */
  1177. showRecentLine(clientX, clientY) {
  1178. const dragLineList = document.querySelectorAll('.drag-line'); // 获取所有的横线
  1179. const dragVerticalLineList = document.querySelectorAll('.drag-vertical-line'); // 获取所有的竖线
  1180. let minDistance = Infinity;
  1181. let minIndex = -1;
  1182. const list = [...dragLineList, ...dragVerticalLineList];
  1183. list.forEach((item, index) => {
  1184. const rect = item.getBoundingClientRect();
  1185. const distance = Math.sqrt(
  1186. Math.pow(clientX - rect.left - rect.width / 2, 2) + Math.pow(clientY - rect.top - rect.height / 2, 2),
  1187. );
  1188. if (distance < minDistance) {
  1189. minDistance = distance;
  1190. minIndex = index;
  1191. }
  1192. });
  1193. list.forEach((item, index) => {
  1194. if (index === minIndex) {
  1195. this.curRow = Number(item.getAttribute('data-row'));
  1196. this.curCol = Number(item.getAttribute('data-col') || -1);
  1197. this.curGrid = Number(item.getAttribute('data-grid') || -1);
  1198. this.gridInsertType = item.getAttribute('data-type') || '';
  1199. item.style.opacity = 1;
  1200. } else {
  1201. item.style.opacity = 0;
  1202. }
  1203. });
  1204. },
  1205. /**
  1206. * 鼠标松开
  1207. */
  1208. dragEnd() {
  1209. document.body.style.userSelect = 'auto';
  1210. const dragging = document.querySelector('.canvas-dragging');
  1211. if (dragging) {
  1212. document.body.removeChild(dragging);
  1213. this.drag.dragging = false;
  1214. }
  1215. if (this.scrollInterval) {
  1216. clearInterval(this.scrollInterval);
  1217. this.scrollInterval = null;
  1218. }
  1219. if (!this.isEdit) return;
  1220. if (this.enterCanvas) {
  1221. if (this.curType === 'component') {
  1222. this.handleComponentMove();
  1223. return;
  1224. }
  1225. if (this.curRow >= -1 && this.curCol <= -1) {
  1226. this.calculateRowInsertedObject();
  1227. }
  1228. if (this.curRow >= -1 && this.curCol > -1 && this.curGrid <= -1) {
  1229. this.calculateColObject();
  1230. }
  1231. if (this.curRow >= -1 && this.curCol > -1 && this.curGrid > -1) {
  1232. this.calculateGridObject();
  1233. }
  1234. }
  1235. this.resetMoveData();
  1236. },
  1237. /**
  1238. * 处理组件移动
  1239. * 组件拖拽移动后,先删除组件,再根据拖拽位置计算插入行、列、格子
  1240. */
  1241. async handleComponentMove() {
  1242. // 放置位置无效(curRow 未在 dragEnd 中被正确更新),放弃移动
  1243. if (this.curRow < -1) {
  1244. this.$message.warning('组件放置位置无效,已取消移动');
  1245. this.resetMoveData();
  1246. return;
  1247. }
  1248. const oldID = this.curParams?.id;
  1249. const component = this.findChildComponentByKey(`grid-${oldID}`);
  1250. // 获取组件内容,保存后再插入到新的位置
  1251. const requestData = component?.getComponentRequestContent();
  1252. const data = component?.data;
  1253. this.curType = data?.type || 'select';
  1254. let col = component?.$attrs['data-col'];
  1255. let row = component?.$attrs['data-row'];
  1256. let grid = component?.$attrs['data-grid'];
  1257. const oldId = component?.oldId || component?.$attrs?.oldId;
  1258. this.curParams = {
  1259. ...this.curParams,
  1260. col,
  1261. row,
  1262. grid,
  1263. oldId,
  1264. };
  1265. // 根据 oldID 改变 data.row_list 中,将对应ID 改为新ID,并返回新ID,在删除时使用
  1266. const deleteID = this.replaceGridIdByOldId(oldID);
  1267. await ContentSaveCoursewareComponentContent(requestData);
  1268. if (this.curRow >= -1 && this.curCol <= -1) {
  1269. this.calculateRowInsertedObject();
  1270. }
  1271. if (this.curRow >= -1 && this.curCol > -1 && this.curGrid <= -1) {
  1272. this.calculateColObject();
  1273. }
  1274. if (this.curRow >= -1 && this.curCol > -1 && this.curGrid > -1) {
  1275. this.calculateGridObject();
  1276. }
  1277. this.$nextTick(() => {
  1278. this.deleteComponent(deleteID);
  1279. });
  1280. },
  1281. /**
  1282. * 生成唯一的 grid id(检查 row_list 中已有的所有 id,确保不重复)
  1283. * @returns {String} 唯一 grid id
  1284. */
  1285. generateUniqueGridId() {
  1286. const allIds = this.data.row_list.flatMap((row) =>
  1287. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  1288. );
  1289. let newID = `ID-${getRandomNumber(12, true)}`;
  1290. while (allIds.includes(newID)) {
  1291. newID = `ID-${getRandomNumber(12, true)}`;
  1292. }
  1293. return newID;
  1294. },
  1295. /**
  1296. * 根据旧组件 id 将 row_list 中对应 grid 的 id 替换为新 id,并返回新 id
  1297. * @param {String} oldID 旧组件 id
  1298. * @returns {String} 新组件 id
  1299. */
  1300. replaceGridIdByOldId(oldID) {
  1301. if (!oldID) return oldID;
  1302. const newID = this.generateUniqueGridId();
  1303. for (const row of this.data.row_list) {
  1304. for (const col of row.col_list) {
  1305. const targetGrid = col.grid_list.find((grid) => grid.id === oldID);
  1306. if (targetGrid) {
  1307. targetGrid.id = newID;
  1308. return newID;
  1309. }
  1310. }
  1311. }
  1312. return oldID;
  1313. },
  1314. resetMoveData() {
  1315. this.curType = '';
  1316. this.curParams = {};
  1317. this.enterCanvas = false;
  1318. },
  1319. /**
  1320. * 计算行样式
  1321. * @param {number} i 行索引
  1322. */
  1323. computedRowStyle(i) {
  1324. let row = this.data.row_list[i];
  1325. let col = row.col_list;
  1326. if (col.length <= 1) {
  1327. return {
  1328. gridTemplateColumns: '0 100fr 0',
  1329. };
  1330. }
  1331. let str = row.width_list
  1332. .map((item) => {
  1333. return `${item}`;
  1334. })
  1335. .join(' 6px ');
  1336. let gridTemplateColumns = `0 ${str} 0`;
  1337. return {
  1338. gridAutoFlow: 'column',
  1339. gridTemplateColumns,
  1340. gridTemplateRows: 'auto',
  1341. };
  1342. },
  1343. /**
  1344. * 计算网格插入的对象
  1345. */
  1346. calculateGridObject() {
  1347. const id = this.curParams?.id || this.generateUniqueGridId();
  1348. const letter = `L${getRandomNumber(6, true)}`;
  1349. const oldId = this.curParams?.oldId;
  1350. let row = this.data.row_list[this.curRow];
  1351. let col = row.col_list[this.curCol];
  1352. let grid = col.grid_list;
  1353. let type = this.gridInsertType;
  1354. if (['row', 'col-middle'].includes(type)) {
  1355. let rowNum = this.curGrid === 0 ? 1 : grid[this.curGrid - 1].row + 1;
  1356. grid.splice(this.curGrid, 0, {
  1357. id,
  1358. grid_area: letter,
  1359. width: '100fr',
  1360. height: 'auto',
  1361. edit_height: 'auto',
  1362. row: rowNum,
  1363. type: this.curType,
  1364. oldId,
  1365. });
  1366. // 在新加入的 grid 后面的 row 都加 1
  1367. grid.forEach((item, i) => {
  1368. if (i > this.curGrid) {
  1369. item.row += 1;
  1370. }
  1371. });
  1372. }
  1373. if (['col-left', 'col-right'].includes(type)) {
  1374. let rowNum = grid[type === 'col-left' ? this.curGrid : this.curGrid - 1].row;
  1375. grid.splice(this.curGrid, 0, {
  1376. id,
  1377. grid_area: letter,
  1378. width: '100fr',
  1379. height: 'auto',
  1380. edit_height: 'auto',
  1381. row: rowNum,
  1382. type: this.curType,
  1383. oldId,
  1384. });
  1385. let allRowNum = grid.filter(({ row }) => row === rowNum).length;
  1386. let w = 0;
  1387. grid.forEach((item, i) => {
  1388. if (item.row === rowNum && i !== this.curGrid) {
  1389. let width = Number(item.width.replace('fr', ''));
  1390. let diff = width / allRowNum;
  1391. item.width = `${width - diff}fr`;
  1392. w += diff;
  1393. }
  1394. });
  1395. grid[this.curGrid].width = `${w}fr`;
  1396. }
  1397. this.resetMoveData();
  1398. },
  1399. /**
  1400. * 计算列插入的对象
  1401. */
  1402. calculateColObject() {
  1403. const id = this.curParams?.id || this.generateUniqueGridId();
  1404. const letter = `L${getRandomNumber(6, true)}`;
  1405. const col_id = `C${getRandomNumber(8, true)}`;
  1406. let row = this.data.row_list[this.curRow];
  1407. let col = row.col_list;
  1408. let w = 0;
  1409. row.width_list.forEach((item, i) => {
  1410. let itemW = Number(item.replace('fr', ''));
  1411. let rowW = itemW / (row.width_list.length + 1);
  1412. w += rowW;
  1413. row.width_list[i] = `${itemW - rowW}fr`;
  1414. });
  1415. row.width_list.splice(this.curCol, 0, `${w}fr`);
  1416. col.splice(this.curCol, 0, {
  1417. col_id,
  1418. width: '100fr',
  1419. height: 'auto',
  1420. grid_list: [
  1421. {
  1422. id,
  1423. grid_area: letter,
  1424. row: 1,
  1425. width: '100fr',
  1426. height: 'auto',
  1427. edit_height: 'auto',
  1428. type: this.curType,
  1429. oldId: this.curParams?.oldId,
  1430. },
  1431. ],
  1432. });
  1433. this.resetMoveData();
  1434. },
  1435. /**
  1436. * 计算行插入的对象
  1437. */
  1438. calculateRowInsertedObject() {
  1439. const id = this.curParams?.id || this.generateUniqueGridId();
  1440. const letter = `L${getRandomNumber(6, true)}`;
  1441. const row_id = `R${getRandomNumber(6, true)}`;
  1442. const col_id = `C${getRandomNumber(8, true)}`;
  1443. this.data.row_list.splice(this.curRow + 1, 0, {
  1444. width_list: ['100fr'],
  1445. row_id,
  1446. col_list: [
  1447. {
  1448. col_id,
  1449. width: '100fr',
  1450. height: 'auto',
  1451. grid_list: [
  1452. {
  1453. id,
  1454. grid_area: letter,
  1455. width: '100fr',
  1456. height: 'auto',
  1457. edit_height: 'auto',
  1458. row: 1,
  1459. type: this.curType,
  1460. oldId: this.curParams?.oldId,
  1461. },
  1462. ],
  1463. },
  1464. ],
  1465. });
  1466. this.resetMoveData();
  1467. },
  1468. /**
  1469. * 获取拖拽元素和画布的边距差值
  1470. * @returns {object} { leftMarginDifference, topMarginDifference, isInsideCanvas }
  1471. * leftMarginDifference: 拖拽元素和画布左边距差值
  1472. * topMarginDifference: 拖拽元素和画布上边距差值
  1473. * isInsideCanvas: 是否在画布内
  1474. */
  1475. getMarginDifferences() {
  1476. const rect1 = document.querySelector('.canvas-dragging').getBoundingClientRect();
  1477. const rect2 = this.$refs.canvas.getBoundingClientRect();
  1478. const leftMarginDifference = rect1.left - rect2.left + 128;
  1479. const topMarginDifference = rect1.top - rect2.top + 72;
  1480. let isInsideCanvas =
  1481. leftMarginDifference > 0 &&
  1482. leftMarginDifference < rect2.width &&
  1483. topMarginDifference > 0 &&
  1484. topMarginDifference < rect2.height;
  1485. return { leftMarginDifference, topMarginDifference, isInsideCanvas };
  1486. },
  1487. // 获取子组件
  1488. findChildComponentByKey(key) {
  1489. return (this.$refs.component || []).find((child) => child.$vnode.key === key);
  1490. },
  1491. /**
  1492. * 初始化画布 ResizeObserver,监听画布高度变化
  1493. * 高度变化时更新 canvasHeight,触发重新渲染以重绘分组线
  1494. */
  1495. initCanvasResizeObserver() {
  1496. const canvas = this.$refs.canvas;
  1497. if (!canvas || typeof ResizeObserver === 'undefined') return;
  1498. this.canvasResizeObserver = new ResizeObserver((entries) => {
  1499. entries.forEach((entry) => {
  1500. const height = entry.contentRect.height;
  1501. if (height !== this.canvasHeight) {
  1502. this.canvasHeight = height;
  1503. }
  1504. });
  1505. });
  1506. this.canvasResizeObserver.observe(canvas);
  1507. },
  1508. /**
  1509. * 销毁画布 ResizeObserver
  1510. */
  1511. destroyCanvasResizeObserver() {
  1512. if (this.canvasResizeObserver) {
  1513. this.canvasResizeObserver.disconnect();
  1514. this.canvasResizeObserver = null;
  1515. }
  1516. },
  1517. /**
  1518. * 计算分组线样式
  1519. * @param {Array} rowIdList 行 ID 列表
  1520. * @returns {Object} 样式对象
  1521. */
  1522. computedGroupLine(rowIdList) {
  1523. // 画布高度变化时(canvasHeight 更新)会触发组件重渲染,此处重新读取 DOM 计算分组线位置
  1524. // 获取画布顶部位置
  1525. const canvas = this.$refs.canvas;
  1526. const firstCheckbox = this.$el.querySelector(`.row-checkbox.${rowIdList[0]}`);
  1527. const secCheckbox = this.$el.querySelector(`.row-checkbox.${rowIdList[1]}`);
  1528. // DOM 未渲染,返回默认样式或空对象
  1529. if (!canvas || !firstCheckbox || !secCheckbox) {
  1530. return {};
  1531. }
  1532. const canvasTop = canvas.getBoundingClientRect().top;
  1533. const firstRowTop = firstCheckbox.getBoundingClientRect().top;
  1534. const secRowTop = secCheckbox.getBoundingClientRect().top;
  1535. return {
  1536. top: `${firstRowTop - canvasTop + 22}px`,
  1537. height: `${secRowTop - firstRowTop - 24}px`,
  1538. };
  1539. },
  1540. computedBorderColor(rowId) {
  1541. const groupList = [];
  1542. this.content_group_row_list.forEach(({ row_id, is_pre_same_group }) => {
  1543. if (is_pre_same_group && groupList.length) {
  1544. groupList[groupList.length - 1].push(row_id);
  1545. } else {
  1546. groupList.push([row_id]);
  1547. }
  1548. });
  1549. const index = groupList.findIndex((g) => g.includes(rowId));
  1550. return this.gridBorderColorList[index % this.gridBorderColorList.length];
  1551. },
  1552. /**
  1553. * 计算网格样式
  1554. * @param {Object} grid 网格对象
  1555. * @returns {Object} 样式对象
  1556. */
  1557. computedGridStyle(grid) {
  1558. let marginTop = grid.row === 1 ? '0' : '6px';
  1559. return {
  1560. gridArea: grid.grid_area,
  1561. height: grid?.edit_height || grid.height,
  1562. marginTop,
  1563. };
  1564. },
  1565. /**
  1566. * 计算网格视图顺序
  1567. * @param {String} id 网格 ID
  1568. * @returns {Number} 顺序值
  1569. */
  1570. computedGridViewOrder(id) {
  1571. // 获取网格 id,是第几行第几列第几个网格,通过 data.row_list 计算
  1572. let rowIndex = -1;
  1573. let colIndex = -1;
  1574. let gridIndex = -1;
  1575. this.data.row_list.forEach((row, i) => {
  1576. row.col_list.forEach((col, j) => {
  1577. col.grid_list.forEach((grid, k) => {
  1578. if (grid.id === id) {
  1579. rowIndex = i;
  1580. colIndex = j;
  1581. gridIndex = k;
  1582. }
  1583. });
  1584. });
  1585. });
  1586. let order = 0;
  1587. // 计算前几行的网格数量
  1588. if (rowIndex > 0) {
  1589. for (let i = 0; i < rowIndex; i++) {
  1590. this.data.row_list[i].col_list.forEach((col) => {
  1591. order += col.grid_list.length;
  1592. });
  1593. }
  1594. }
  1595. // 计算当前行前几列的网格数量
  1596. if (colIndex > 0) {
  1597. for (let j = 0; j < colIndex; j++) {
  1598. order += this.data.row_list[rowIndex].col_list[j].grid_list.length;
  1599. }
  1600. }
  1601. // 加上当前列前面的网格数量
  1602. order += gridIndex + 1;
  1603. return order;
  1604. },
  1605. /**
  1606. * 归一化 row_list 中的 row_id / col_id(为后端旧数据补 id)
  1607. */
  1608. normalizeRowColIds(data) {
  1609. if (!data || !Array.isArray(data.row_list)) return data;
  1610. data.row_list = data.row_list.map((row) => {
  1611. if (!row.row_id) row.row_id = `R${getRandomNumber(6, true)}`;
  1612. if (!Array.isArray(row.col_list)) row.col_list = [];
  1613. row.col_list = row.col_list.map((col) => {
  1614. if (!col.col_id) col.col_id = `C${getRandomNumber(8, true)}`;
  1615. return col;
  1616. });
  1617. return row;
  1618. });
  1619. return data;
  1620. },
  1621. /**
  1622. * 过滤无组件的空行,并将分组记录按行顺序归一化为一行一条。
  1623. * @param {Array} rowList 行列表
  1624. * @param {Array} groupList 行分组列表
  1625. * @param {boolean} regenerateRowIds 是否为所有行重新生成 ID
  1626. */
  1627. normalizeCanvasRows(rowList, groupList, regenerateRowIds = false) {
  1628. const groupQueues = new Map();
  1629. (groupList || []).forEach((group) => {
  1630. if (!groupQueues.has(group.row_id)) groupQueues.set(group.row_id, []);
  1631. groupQueues.get(group.row_id).push(group);
  1632. });
  1633. const normalizedRows = [];
  1634. const normalizedGroups = [];
  1635. const usedRowIds = new Set();
  1636. (rowList || []).forEach((row) => {
  1637. const sourceRowId = row?.row_id;
  1638. const group = groupQueues.get(sourceRowId)?.shift() || {};
  1639. const columns = Array.isArray(row?.col_list) ? row.col_list : [];
  1640. if (!columns.some((col) => Array.isArray(col?.grid_list) && col.grid_list.length > 0)) return;
  1641. let rowId = sourceRowId;
  1642. if (regenerateRowIds || !rowId || usedRowIds.has(rowId)) {
  1643. do {
  1644. rowId = `R${getRandomNumber(6, true)}`;
  1645. } while (usedRowIds.has(rowId));
  1646. }
  1647. usedRowIds.add(rowId);
  1648. normalizedRows.push({ ...row, row_id: rowId });
  1649. normalizedGroups.push({
  1650. ...group,
  1651. row_id: rowId,
  1652. is_pre_same_group: normalizedGroups.length > 0 && Boolean(group.is_pre_same_group),
  1653. });
  1654. });
  1655. return { rowList: normalizedRows, groupRowList: normalizedGroups };
  1656. },
  1657. /**
  1658. * 删除行后归一化行分组列表
  1659. *
  1660. * is_pre_same_group 为 true 表示该行与其前一行同属一个分组。
  1661. * 行删除后,content_group_row_list 中剩余行的 is_pre_same_group 仍是删除前的值,
  1662. * 若某分组的首行被删除,则组内剩余首行的 is_pre_same_group 需置为 false,
  1663. * 否则分组线(.group-line)会错误地连接到前一组。
  1664. *
  1665. * @param {Array} groupList 删除前(含新增行)的行分组列表
  1666. * @param {Array} rowList 删除后的 row_list
  1667. * @returns {Array} 归一化后的行分组列表
  1668. */
  1669. normalizeGroupRowList(groupList, rowList) {
  1670. const validRowIds = new Set((rowList || []).map((row) => row.row_id));
  1671. // 按删除前的分组划分:is_pre_same_group 为 false 的行是新分组的首行
  1672. const groups = [];
  1673. let curGroup = [];
  1674. (groupList || []).forEach((item) => {
  1675. if (item.is_pre_same_group) {
  1676. curGroup.push(item);
  1677. } else {
  1678. if (curGroup.length) groups.push(curGroup);
  1679. curGroup = [item];
  1680. }
  1681. });
  1682. if (curGroup.length) groups.push(curGroup);
  1683. // 过滤掉已删除的行,并将每组剩余首行的 is_pre_same_group 置为 false,其余保持 true
  1684. const result = [];
  1685. groups.forEach((group) => {
  1686. const remaining = group.filter((item) => validRowIds.has(item.row_id));
  1687. if (!remaining.length) return;
  1688. remaining.forEach((item, i) => {
  1689. item.is_pre_same_group = i > 0;
  1690. });
  1691. result.push(...remaining);
  1692. });
  1693. return result;
  1694. },
  1695. /**
  1696. * 加载模板数据
  1697. * @param {Object} param
  1698. * @param {Object} param.data - 模板数据
  1699. * @param {Array} param.content_group_row_list - 内容分组行列表
  1700. * @param {Array} param.backgroundList - 组件背景列表
  1701. */
  1702. async loadTemplateData_CreateCanvas({ data, content_group_row_list, backgroundList }) {
  1703. // 深拷贝:避免与「选择模板」弹窗缓存的模板数据共享引用(否则画布改动会污染模板数据)
  1704. const templateData = _.cloneDeep(data) || { row_list: [] };
  1705. if (!Array.isArray(templateData.row_list)) templateData.row_list = [];
  1706. const normalized = this.normalizeCanvasRows(templateData.row_list, _.cloneDeep(content_group_row_list) || []);
  1707. templateData.row_list = normalized.rowList;
  1708. this.data = templateData;
  1709. this.content_group_row_list = normalized.groupRowList;
  1710. // 清空模板数据中残留的 oldId,避免按陈旧的来源组件 id 取到错误内容
  1711. this.clearGridOldId(this.data.row_list);
  1712. await this.insertComponentBackground(this.data.row_list, backgroundList, 'id');
  1713. },
  1714. /**
  1715. * 插入模板数据
  1716. * @param {Object} param
  1717. * @param {Object} param.row_list - 模板组件列表
  1718. * @param {Array} param.content_group_row_list - 内容分组行列表
  1719. * @param {Array} param.backgroundList - 组件背景列表
  1720. */
  1721. async insertTemplateData_CreateCanvas({ row_list = [], content_group_row_list = [], backgroundList = [] }) {
  1722. const normalized = this.normalizeCanvasRows(_.cloneDeep(row_list), content_group_row_list, true);
  1723. let contentGroupRowList = normalized.groupRowList;
  1724. // 插入的内容保持独立分组,不并入插入位置上方的分组(模板数据异常时兜底)
  1725. if (contentGroupRowList.length > 0) contentGroupRowList[0].is_pre_same_group = false;
  1726. // 深拷贝后再改写列和组件 id,避免污染「选择模板」弹窗缓存的数据。
  1727. let rowList = normalized.rowList.map((row) => {
  1728. row.col_list = (row.col_list || []).map((col) => {
  1729. col.col_id = `C${getRandomNumber(8, true)}`;
  1730. col.grid_list = (col.grid_list || []).map((grid) => {
  1731. // oldId 用于子组件从模板课件取组件内容(temporaryCoursewareID + oldId)
  1732. grid.oldId = grid.id;
  1733. grid.id = this.generateUniqueGridId();
  1734. grid.grid_area = `L${getRandomNumber(6, true)}`;
  1735. return grid;
  1736. });
  1737. return col;
  1738. });
  1739. return row;
  1740. });
  1741. const curId = this.getCurSettingId();
  1742. if (curId) {
  1743. // 插入到当前选中组件后面
  1744. const attrs = this.findChildComponentByKey(`grid-${curId}`)?.$attrs;
  1745. if (!attrs) return;
  1746. const i = Number(attrs['data-row']);
  1747. this.data.row_list.splice(i + 1, 0, ...rowList);
  1748. this.content_group_row_list.splice(i + 1, 0, ...contentGroupRowList);
  1749. // 断开插入内容之后那一行的分组,避免模板内容被并入原有分组(与「插入行」的处理保持一致)
  1750. this.breakGroupFrom(i + 1 + contentGroupRowList.length);
  1751. } else {
  1752. // 无选中组件,插入到最后
  1753. this.data.row_list.push(...rowList);
  1754. this.content_group_row_list.push(...contentGroupRowList);
  1755. }
  1756. await this.insertComponentBackground(rowList, backgroundList);
  1757. },
  1758. /**
  1759. * 清除组件上仅用于「从模板课件取内容」的临时标记 oldId
  1760. * 保存时组件内容已按组件自身 id 写入当前课件,若继续保留 oldId,
  1761. * 该课件之后再次被当作模板使用时组件会按陈旧的 oldId 请求而取到空内容
  1762. * @param {Array} row_list - 行列表,默认当前画布的行列表
  1763. */
  1764. clearGridOldId(row_list = this.data.row_list) {
  1765. (row_list || []).forEach((row) => {
  1766. (row.col_list || []).forEach((col) => {
  1767. (col.grid_list || []).forEach((grid) => {
  1768. delete grid.oldId;
  1769. });
  1770. });
  1771. });
  1772. },
  1773. /**
  1774. * 断开指定下标行与其上一行的分组关系(分组线、边框颜色、保存时的分组都会随之更新)
  1775. * @param {number} startIndex - 起始下标
  1776. */
  1777. breakGroupFrom(startIndex) {
  1778. for (let i = startIndex; i >= 0 && i < this.content_group_row_list.length; i++) {
  1779. if (this.content_group_row_list[i].is_pre_same_group) {
  1780. this.content_group_row_list[i].is_pre_same_group = false;
  1781. } else {
  1782. break;
  1783. }
  1784. }
  1785. },
  1786. /**
  1787. * 插入组件背景
  1788. * @param {Array} row_list - 行列表
  1789. * @param {Array} backgroundList - 组件背景列表
  1790. * @param {string} gridIdName - ID 属性名,默认为 'oldId'
  1791. */
  1792. async insertComponentBackground(row_list, backgroundList, gridIdName = 'oldId') {
  1793. if (!Array.isArray(row_list) || !Array.isArray(backgroundList)) return;
  1794. await this.$nextTick();
  1795. row_list.forEach((row) => {
  1796. row.col_list.forEach((col) => {
  1797. col.grid_list.forEach((grid) => {
  1798. const background = backgroundList.find((bg) => bg.component_id === grid[gridIdName]);
  1799. const bg = background?.background;
  1800. if (bg) {
  1801. const component = this.findChildComponentByKey(`grid-${grid.id}`);
  1802. if (component) component.background = bg;
  1803. }
  1804. });
  1805. });
  1806. });
  1807. },
  1808. /**
  1809. * @description 复制组件
  1810. * @param {object} data 组件数据
  1811. */
  1812. copyComponent(data) {
  1813. this.$emit('copyComponent', data);
  1814. },
  1815. /**
  1816. * @description 粘贴组件
  1817. * @param {Object} componentData 组件数据
  1818. * @param {String} position 放入位置类型
  1819. */
  1820. pasteComponent(componentData, position) {
  1821. if (!componentData) return;
  1822. let _data = JSON.parse(JSON.stringify(componentData));
  1823. const curId = this.getCurSettingId();
  1824. const attrs = this.findChildComponentByKey(`grid-${curId}`)?.$attrs;
  1825. if (!attrs) return;
  1826. const i = Number(attrs['data-row']);
  1827. const j = Number(attrs['data-col']);
  1828. let k = Number(attrs['data-grid']);
  1829. let type = _data.type;
  1830. let row = this.data.row_list[i];
  1831. let col = row.col_list[j];
  1832. let grid = col.grid_list;
  1833. const id = this.generateUniqueGridId();
  1834. const letter = `L${getRandomNumber(6, true)}`;
  1835. if (['top', 'bottom'].includes(position)) {
  1836. let rowNum = k === 0 ? 1 : grid[k - 1].row + 1;
  1837. if (position === 'bottom') {
  1838. rowNum = grid[k].row + 1;
  1839. }
  1840. k = position === 'top' ? k : k + 1;
  1841. grid.splice(k, 0, {
  1842. id,
  1843. grid_area: letter,
  1844. width: '100fr',
  1845. height: 'auto',
  1846. edit_height: 'auto',
  1847. row: rowNum,
  1848. type,
  1849. });
  1850. grid.forEach((item, i) => {
  1851. if (i > k) {
  1852. item.row += 1;
  1853. }
  1854. });
  1855. }
  1856. if (['left', 'right'].includes(position)) {
  1857. let rowNum = grid[k].row;
  1858. let _k = position === 'left' ? k : k + 1;
  1859. grid.splice(_k, 0, {
  1860. id,
  1861. grid_area: letter,
  1862. width: '100fr',
  1863. height: 'auto',
  1864. edit_height: 'auto',
  1865. row: rowNum,
  1866. type,
  1867. });
  1868. let allRowNum = grid.filter(({ row }) => row === rowNum).length;
  1869. let w = 0;
  1870. grid.forEach((item, i) => {
  1871. if (item.row === rowNum && i !== k) {
  1872. let width = Number(item.width.replace('fr', ''));
  1873. let diff = width / allRowNum;
  1874. item.width = `${width - diff}fr`;
  1875. w += diff;
  1876. }
  1877. });
  1878. grid[_k].width = `${w}fr`;
  1879. }
  1880. this.$nextTick(() => {
  1881. let newComponent = this.findChildComponentByKey(`grid-${id}`); // 获取新添加的组件实例
  1882. newComponent?.setData(_data); // 设置新添加的组件数据
  1883. });
  1884. },
  1885. // 交换组件位置
  1886. switchComponent() {
  1887. let checkedNum = 0;
  1888. let components = [];
  1889. this.$refs.component.forEach((child) => {
  1890. if (child.$refs.base.checked) {
  1891. checkedNum += 1;
  1892. components.push(child.id);
  1893. }
  1894. });
  1895. if (checkedNum !== 2) {
  1896. this.$message.error('请选择两个组件进行交换');
  1897. return;
  1898. }
  1899. this.$confirm('组件未保存的改动将丢失。确定要交换这两个组件的位置吗?', '提示', {
  1900. confirmButtonText: '确定',
  1901. cancelButtonText: '取消',
  1902. type: 'warning',
  1903. })
  1904. .then(() => {
  1905. this.switchComponentData(components);
  1906. })
  1907. .catch(() => {
  1908. // 取消交换
  1909. });
  1910. },
  1911. /**
  1912. * 合并遍历 data.row_list,找到两个网格对象并直接交换它们的属性
  1913. * @param {Array} components 组件 ID 列表
  1914. */
  1915. switchComponentData(components) {
  1916. const [id1, id2] = components;
  1917. let g1 = null;
  1918. let g2 = null;
  1919. for (const row of this.data.row_list) {
  1920. for (const col of row.col_list) {
  1921. for (const grid of col.grid_list) {
  1922. if (grid.id === id1) g1 = grid;
  1923. else if (grid.id === id2) g2 = grid;
  1924. if (g1 && g2) break;
  1925. }
  1926. if (g1 && g2) break;
  1927. }
  1928. if (g1 && g2) break;
  1929. }
  1930. if (!g1 || !g2) return;
  1931. const tmp = { grid_area: g1.grid_area, id: g1.id, type: g1.type }; // 临时存储 g1 的属性
  1932. g1.grid_area = g2.grid_area;
  1933. g1.type = g2.type;
  1934. g1.id = g2.id;
  1935. g2.grid_area = tmp.grid_area;
  1936. g2.type = tmp.type;
  1937. g2.id = tmp.id;
  1938. },
  1939. clearContent() {
  1940. this.data.row_list = [];
  1941. this.content_group_row_list = [];
  1942. },
  1943. showSetComponentBackground() {
  1944. this.$refs.previewEdit.showSetComponentBackground();
  1945. },
  1946. },
  1947. };
  1948. </script>
  1949. <style lang="scss" scoped>
  1950. .canvas {
  1951. .edit {
  1952. position: relative;
  1953. display: flex;
  1954. flex-direction: column;
  1955. row-gap: 8px;
  1956. width: $courseware-width;
  1957. min-height: calc(100vh - 240px);
  1958. padding: 24px;
  1959. margin: 0 auto;
  1960. background-color: #fff;
  1961. .group-lines {
  1962. position: absolute;
  1963. top: 0;
  1964. left: 0;
  1965. pointer-events: none;
  1966. }
  1967. .group-line {
  1968. position: absolute;
  1969. left: 11px;
  1970. width: 1px;
  1971. background-color: #165dff;
  1972. }
  1973. .row {
  1974. display: grid;
  1975. row-gap: 16px;
  1976. .row-checkbox {
  1977. position: absolute;
  1978. left: 4px;
  1979. }
  1980. > .drag-vertical-line:not(:first-child, :last-child, .grid-line) {
  1981. left: 6px;
  1982. }
  1983. .drag-vertical-line.grid-line-right,
  1984. .drag-vertical-line.grid-line-left {
  1985. top: 25%;
  1986. height: 50%;
  1987. }
  1988. .drag-vertical-line.col-start {
  1989. left: -12px;
  1990. }
  1991. .drag-vertical-line.col-end {
  1992. right: -8px;
  1993. }
  1994. .col {
  1995. display: grid;
  1996. .drag-vertical-line:first-child {
  1997. left: -8px;
  1998. }
  1999. .drag-vertical-line:not(:first-child, :last-child, .grid-line) {
  2000. left: 6px;
  2001. }
  2002. .drag-vertical-line:last-child {
  2003. right: -4px;
  2004. }
  2005. }
  2006. }
  2007. .drag-line {
  2008. z-index: 2;
  2009. width: calc(100% - 16px);
  2010. height: 4px;
  2011. margin: 0 8px;
  2012. background-color: #379fff;
  2013. border-radius: 4px;
  2014. opacity: 0;
  2015. &.drag-row {
  2016. width: 50%;
  2017. margin-left: 25%;
  2018. background-color: $right-color;
  2019. }
  2020. &.grid-line:not(:first-child, :last-child) {
  2021. position: relative;
  2022. top: 6px;
  2023. }
  2024. &.grid-line {
  2025. background-color: #f43;
  2026. }
  2027. }
  2028. .drag-vertical-line {
  2029. position: relative;
  2030. z-index: 2;
  2031. width: 4px;
  2032. height: 100%;
  2033. background-color: #f43;
  2034. border-radius: 4px;
  2035. opacity: 0;
  2036. &.grid-line {
  2037. background-color: #f43;
  2038. }
  2039. }
  2040. }
  2041. }
  2042. </style>
  2043. <style lang="scss">
  2044. .canvas-dragging {
  2045. position: fixed;
  2046. z-index: 999;
  2047. width: 320px;
  2048. height: 180px;
  2049. background-color: #eaf5ff;
  2050. border: 1px solid #b5dbff;
  2051. border-radius: 4px;
  2052. opacity: 0.5;
  2053. transform: translate(-40%, -40%);
  2054. &.component-dragging {
  2055. background-color: #f9ffe0;
  2056. border-color: #f9ffe0;
  2057. }
  2058. }
  2059. </style>