CoursewarePreview.vue 12 KB

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