CoursewarePreview.vue 12 KB

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