CoursewarePreview.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. <template>
  2. <div ref="courserware" class="courserware" :style="computedCourserwareStyle()">
  3. <template v-for="(row, i) in data.row_list">
  4. <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
  5. <el-checkbox
  6. v-if="
  7. isShowGroup &&
  8. groupRowList.find(({ row_id, is_pre_same_group }) => row.row_id === row_id && !is_pre_same_group)
  9. "
  10. v-model="rowCheckList[row.row_id]"
  11. :class="['row-checkbox', `${row.row_id}`]"
  12. />
  13. <!-- 列 -->
  14. <template v-for="(col, j) in row.col_list">
  15. <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
  16. <!-- 网格 -->
  17. <template v-for="(grid, k) in col.grid_list">
  18. <component
  19. :is="previewComponentList[grid.type]"
  20. :id="grid.id"
  21. :key="k"
  22. ref="preview"
  23. :content="computedColContent(grid.id)"
  24. :class="[grid.id]"
  25. :data-id="grid.id"
  26. :style="{
  27. gridArea: grid.grid_area,
  28. height: grid.height,
  29. }"
  30. @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
  31. @handleHeightChange="handleHeightChange"
  32. />
  33. <div
  34. v-if="showMenu && componentId === grid.id"
  35. :key="'menu' + grid.id + k"
  36. class="custom-context-menu"
  37. :style="{ left: menuPosition.x + 'px', top: menuPosition.y + 'px' }"
  38. @click="handleMenuItemClick"
  39. >
  40. 添加批注
  41. </div>
  42. <div
  43. v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj[grid.id]"
  44. :key="'show' + grid.id + k"
  45. >
  46. <el-popover
  47. v-for="(items, indexs) in componentRemarkObj[grid.id]"
  48. :key="indexs"
  49. placement="bottom"
  50. width="200"
  51. trigger="click"
  52. >
  53. <div v-html="items.content"></div>
  54. <template #reference>
  55. <SvgIcon
  56. slot="reference"
  57. icon-class="icon-info"
  58. size="24"
  59. class="remark-info"
  60. :style="{ left: items.position_x - 12 + 'px', top: items.position_y - 12 + 'px' }"
  61. />
  62. </template>
  63. </el-popover>
  64. </div>
  65. </template>
  66. </div>
  67. </template>
  68. </div>
  69. </template>
  70. </div>
  71. </template>
  72. <script>
  73. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  74. export default {
  75. name: 'CoursewarePreview',
  76. provide() {
  77. return {
  78. getDragStatus: () => false,
  79. bookInfo: this.bookInfo,
  80. };
  81. },
  82. props: {
  83. data: {
  84. type: Object,
  85. default: () => ({}),
  86. },
  87. background: {
  88. type: Object,
  89. default: () => ({}),
  90. },
  91. componentList: {
  92. type: Array,
  93. required: true,
  94. },
  95. canRemark: {
  96. type: Boolean,
  97. default: false,
  98. },
  99. showRemark: {
  100. type: Boolean,
  101. default: false,
  102. },
  103. componentRemarkObj: {
  104. type: Object,
  105. default: () => ({}),
  106. },
  107. groupRowList: {
  108. type: Array,
  109. default: () => [],
  110. },
  111. isShowGroup: {
  112. type: Boolean,
  113. default: false,
  114. },
  115. groupShowAll: {
  116. type: Boolean,
  117. default: true,
  118. },
  119. project: {
  120. type: Object,
  121. default: () => ({}),
  122. },
  123. },
  124. data() {
  125. return {
  126. previewComponentList,
  127. bookInfo: {
  128. theme_color: '',
  129. },
  130. showMenu: false,
  131. divPosition: {
  132. left: 0,
  133. top: 0,
  134. }, // courserware盒子原始距离页面顶部和左边的距离
  135. menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
  136. componentId: '', // 添加批注的组件id
  137. rowCheckList: {},
  138. };
  139. },
  140. watch: {
  141. groupRowList: {
  142. handler(val) {
  143. if (!val) return;
  144. this.rowCheckList = val
  145. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  146. .reduce((acc, row) => {
  147. acc[row.row_id] = false;
  148. return acc;
  149. }, {});
  150. },
  151. },
  152. },
  153. mounted() {
  154. const element = this.$refs.courserware;
  155. const rect = element.getBoundingClientRect();
  156. this.divPosition = {
  157. left: rect.left,
  158. top: rect.top,
  159. };
  160. window.addEventListener('mousedown', this.handleMouseDown);
  161. },
  162. beforeDestroy() {
  163. window.removeEventListener('mousedown', this.handleMouseDown);
  164. },
  165. methods: {
  166. /**
  167. * 计算组件内容
  168. * @param {string} id 组件id
  169. * @returns {string} 组件内容
  170. */
  171. computedColContent(id) {
  172. if (!id) return '';
  173. return this.componentList.find((item) => item.component_id === id)?.content || '';
  174. },
  175. getMultipleColStyle(i) {
  176. let row = this.data.row_list[i];
  177. let col = row.col_list;
  178. if (col.length <= 1) {
  179. return {
  180. gridTemplateColumns: '100fr',
  181. };
  182. }
  183. let gridTemplateColumns = row.width_list.join(' ');
  184. return {
  185. gridAutoFlow: 'column',
  186. gridTemplateColumns,
  187. gridTemplateRows: 'auto',
  188. };
  189. },
  190. /**
  191. * 计算行的可见性
  192. * @params {string} rowId 行的ID
  193. */
  194. computedRowVisibility(rowId) {
  195. if (this.groupShowAll) return true;
  196. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  197. if (is_pre_same_group) {
  198. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  199. if (index === -1) return false;
  200. for (let i = index - 1; i >= 0; i--) {
  201. if (!this.groupRowList[i].is_pre_same_group) {
  202. return this.rowCheckList[this.groupRowList[i].row_id];
  203. }
  204. }
  205. return false;
  206. }
  207. return this.rowCheckList[row_id];
  208. },
  209. /**
  210. * 分割整数为多个 1的倍数
  211. * @param {number} num
  212. * @param {number} parts
  213. */
  214. splitInteger(num, parts) {
  215. let base = Math.floor(num / parts);
  216. let arr = Array(parts).fill(base);
  217. let remainder = num - base * parts;
  218. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  219. arr[i] += 1;
  220. remainder -= 1;
  221. }
  222. return arr;
  223. },
  224. computedColStyle(col) {
  225. const grid = col.grid_list;
  226. let maxCol = 0; // 最大列数
  227. let rowList = new Map();
  228. grid.forEach(({ row }) => {
  229. rowList.set(row, (rowList.get(row) || 0) + 1);
  230. });
  231. let curMaxRow = 0; // 当前数量最大 row 的值
  232. rowList.forEach((value, key) => {
  233. if (value > maxCol) {
  234. maxCol = value;
  235. curMaxRow = key;
  236. }
  237. });
  238. // 计算 grid_template_areas
  239. let gridTemplateAreas = '';
  240. let gridArr = [];
  241. grid.forEach(({ grid_area, row }) => {
  242. if (!gridArr[row - 1]) {
  243. gridArr[row - 1] = [];
  244. }
  245. if (curMaxRow === row) {
  246. gridArr[row - 1].push(`${grid_area}`);
  247. } else {
  248. let filter = grid.filter((item) => item.row === row);
  249. let find = filter.findIndex((item) => item.grid_area === grid_area);
  250. let needNum = maxCol - filter.length; // 需要的数量
  251. let str = '';
  252. if (filter.length === 1) {
  253. str = ` ${grid_area} `.repeat(needNum + 1);
  254. } else {
  255. let arr = this.splitInteger(needNum, filter.length);
  256. str = arr[find] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(arr[find] + 1);
  257. }
  258. gridArr[row - 1].push(`${str}`);
  259. }
  260. });
  261. gridArr.forEach((item) => {
  262. gridTemplateAreas += `'${item.join(' ')}' `;
  263. });
  264. // 计算 grid_template_columns
  265. let gridTemplateColumns = '';
  266. let max = { row: 0, num: 0 };
  267. grid.forEach(({ row }) => {
  268. // 计算出 row 的哪个值最多
  269. let len = grid.filter((item) => item.row === row).length;
  270. if (max.num < len) {
  271. max.num = len;
  272. max.row = row;
  273. }
  274. });
  275. grid.forEach((item) => {
  276. if (item.row === max.row) {
  277. gridTemplateColumns += `${item.width} `;
  278. }
  279. });
  280. // 计算 grid_template_rows
  281. let gridTemplateRows = '';
  282. // 将 grid 按照 row 分组
  283. let gridMap = new Map();
  284. grid.forEach((item) => {
  285. if (!gridMap.has(item.row)) {
  286. gridMap.set(item.row, []);
  287. }
  288. gridMap.get(item.row).push(item.height);
  289. });
  290. gridMap.forEach((value) => {
  291. if (value.length === 1) {
  292. gridTemplateRows += `${value[0]} `;
  293. } else {
  294. let isAllAuto = value.every((item) => item === 'auto'); // 是否全是 auto
  295. gridTemplateRows += isAllAuto ? 'auto ' : `max(${value.join(', ')}) `;
  296. }
  297. });
  298. return {
  299. width: col.width,
  300. gridTemplateAreas,
  301. gridTemplateColumns,
  302. gridTemplateRows,
  303. };
  304. },
  305. /**
  306. * 计算课件背景样式
  307. * @returns {Object} 课件背景样式对象
  308. */
  309. computedCourserwareStyle() {
  310. const { background_image_url: bcImgUrl = '', background_position: pos = {} } = this.background || {};
  311. const hasNoRows = !Array.isArray(this.data?.row_list) || this.data.row_list.length === 0;
  312. const projectCover = this.project?.cover_image_file_url || '';
  313. // 优先在空行时使用背景图或项目封面
  314. const backgroundImage = hasNoRows ? bcImgUrl || projectCover : '';
  315. // 保护性读取位置/大小值,避免 undefined 导致字符串 "undefined%"
  316. const widthPct = typeof pos.width === 'undefined' ? '' : pos.width;
  317. const heightPct = typeof pos.height === 'undefined' ? '' : pos.height;
  318. const leftPct = typeof pos.left === 'undefined' ? '' : pos.left;
  319. const topPct = typeof pos.top === 'undefined' ? '' : pos.top;
  320. const hasBcImg = Boolean(bcImgUrl);
  321. const backgroundSize = hasBcImg ? `${widthPct}% ${heightPct}%` : hasNoRows ? 'contain' : '';
  322. const backgroundPosition = hasBcImg ? `${leftPct}% ${topPct}%` : hasNoRows ? 'center' : '';
  323. return {
  324. backgroundImage: backgroundImage ? `url(${backgroundImage})` : '',
  325. backgroundSize,
  326. backgroundPosition,
  327. };
  328. },
  329. handleContextMenu(event, id) {
  330. if (this.canRemark) {
  331. event.preventDefault(); // 阻止默认的上下文菜单显示
  332. this.menuPosition = {
  333. x: event.clientX - this.divPosition.left,
  334. y: event.clientY - this.divPosition.top,
  335. }; // 设置菜单位置
  336. this.componentId = id;
  337. this.$emit('computeScroll');
  338. }
  339. },
  340. handleResult(top, left, select_node) {
  341. this.menuPosition = {
  342. x: this.menuPosition.x + left,
  343. y: this.menuPosition.y + top,
  344. select_node,
  345. }; // 设置菜单位置
  346. this.showMenu = true; // 显示菜单
  347. },
  348. handleMenuItemClick() {
  349. this.showMenu = false; // 隐藏菜单
  350. this.$emit(
  351. 'addRemark',
  352. this.menuPosition.select_node,
  353. this.menuPosition.x,
  354. this.menuPosition.y,
  355. this.componentId,
  356. );
  357. },
  358. handleMouseDown(event) {
  359. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  360. // 0 表示左键
  361. this.showMenu = false;
  362. }
  363. },
  364. /**
  365. * 查找子组件
  366. * @param {string} id 组件的唯一标识符
  367. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  368. */
  369. async findChildComponentByKey(id) {
  370. await this.$nextTick();
  371. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  372. },
  373. /**
  374. * 模拟回答
  375. * @param {boolean} isJudgingRightWrong 是否判断对错
  376. * @param {boolean} isShowRightAnswer 是否显示正确答案
  377. * @param {boolean} disabled 是否禁用
  378. */
  379. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
  380. this.$refs.preview.forEach((item) => {
  381. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
  382. });
  383. },
  384. /**
  385. * 处理组件高度变化事件
  386. * @param {string} id 组件id
  387. * @param {string} newHeight 组件的新高度
  388. */
  389. handleHeightChange(id, newHeight) {
  390. this.data.row_list.forEach((row) => {
  391. row.col_list.forEach((col) => {
  392. col.grid_list.forEach((grid) => {
  393. if (grid.id === id) {
  394. grid.height = newHeight;
  395. }
  396. });
  397. });
  398. });
  399. },
  400. },
  401. };
  402. </script>
  403. <style lang="scss" scoped>
  404. .courserware {
  405. position: relative;
  406. display: flex;
  407. flex-direction: column;
  408. row-gap: $component-spacing;
  409. width: 100%;
  410. height: 100%;
  411. min-height: calc(100vh - 226px);
  412. padding-top: $courseware-top-padding;
  413. padding-bottom: $courseware-bottom-padding;
  414. margin: 15px 0;
  415. background-color: #fff;
  416. background-repeat: no-repeat;
  417. border-bottom-right-radius: 12px;
  418. border-bottom-left-radius: 12px;
  419. &::before,
  420. &::after {
  421. position: absolute;
  422. left: 0;
  423. width: 100%;
  424. height: 15px;
  425. pointer-events: none;
  426. content: '';
  427. background: $courseware-bgColor;
  428. }
  429. &::before {
  430. top: -15px;
  431. }
  432. &::after {
  433. bottom: -15px;
  434. }
  435. .row {
  436. display: grid;
  437. gap: $component-spacing;
  438. .col {
  439. display: grid;
  440. gap: $component-spacing;
  441. overflow: hidden;
  442. }
  443. .row-checkbox {
  444. position: absolute;
  445. left: 4px;
  446. }
  447. }
  448. .custom-context-menu,
  449. .remark-info {
  450. position: absolute;
  451. z-index: 999;
  452. display: flex;
  453. gap: 3px;
  454. align-items: center;
  455. font-size: 14px;
  456. cursor: pointer;
  457. }
  458. .custom-context-menu {
  459. padding-left: 30px;
  460. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  461. background-size: 24px;
  462. }
  463. }
  464. </style>