CoursewarePreview.vue 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307
  1. <template>
  2. <div
  3. id="selectable-area-preview"
  4. ref="courseware"
  5. class="courseware preview"
  6. :style="computedCoursewareStyle"
  7. @mouseup="handleTextSelection"
  8. @mousedown="startSelection"
  9. @mousemove="updateSelection"
  10. >
  11. <!-- @mouseleave="endSelection" -->
  12. <template v-for="(row, i) in data.row_list">
  13. <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
  14. <el-checkbox
  15. v-if="
  16. isShowGroup &&
  17. groupRowList.find(({ row_id, is_pre_same_group }) => row.row_id === row_id && !is_pre_same_group)
  18. "
  19. v-model="rowCheckList[row.row_id]"
  20. :class="['row-checkbox', `${row.row_id}`]"
  21. />
  22. <!-- 列 -->
  23. <template v-for="(col, j) in row.col_list">
  24. <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
  25. <!-- 网格 -->
  26. <template v-for="(grid, k) in col.grid_list">
  27. <component
  28. :is="previewComponentList[grid.type]"
  29. :id="grid.id"
  30. :key="k"
  31. ref="preview"
  32. :content="computedColContent(grid.id)"
  33. :background="computedColBackground(grid.id)"
  34. :class="['grid', grid.id, { active: curSelectId === grid.id }]"
  35. :data-id="grid.id"
  36. :style="{
  37. gridArea: grid.grid_area,
  38. height: grid.height,
  39. }"
  40. :is-mobile="isMobile"
  41. @click.native="selectedComponent(grid.id)"
  42. @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
  43. @handleHeightChange="handleHeightChange"
  44. />
  45. <!-- <div
  46. v-if="showMenu && componentId === grid.id"
  47. :key="'menu' + grid.id + k"
  48. class="custom-context-menu"
  49. :style="{ left: menuPosition.x + 'px', top: menuPosition.y + 'px' }"
  50. @click="handleMenuItemClick"
  51. >
  52. 添加批注
  53. </div> -->
  54. <div
  55. v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj[grid.id]"
  56. :key="'show' + grid.id + k"
  57. >
  58. <el-popover
  59. v-for="(items, indexs) in componentRemarkObj[grid.id]"
  60. :key="indexs"
  61. placement="bottom"
  62. trigger="click"
  63. popper-class="menu-remark-info"
  64. >
  65. <div v-html="items.content"></div>
  66. <template v-if="items.file_list.length > 0">
  67. <div v-for="(item, index) in items.file_list" :key="indexs + '-' + index" class="remark-file-item">
  68. <SvgIcon :icon-class="item.icon_type" />
  69. <span class="file-item-name">{{ item.file_name }}</span>
  70. <SvgIcon icon-class="uploadPreview" @click="viewDialog(item)" />
  71. <SvgIcon icon-class="download" @click="downLoad(item)" />
  72. </div>
  73. </template>
  74. <template #reference>
  75. <div
  76. v-if="items.position_br_x - items.position_x > 3 && items.position_br_y - items.position_y > 3"
  77. slot="reference"
  78. :style="{
  79. position: 'absolute',
  80. top: `${items.position_y}px`,
  81. left: `${items.position_x}px`,
  82. width: `${items.position_br_x - items.position_x}px`,
  83. height: `${items.position_br_y - items.position_y}px`,
  84. border: '2px solid #165DFF',
  85. zIndex: 10,
  86. }"
  87. ></div>
  88. </template>
  89. </el-popover>
  90. </div>
  91. </template>
  92. </div>
  93. </template>
  94. </div>
  95. </template>
  96. <div
  97. v-if="menuPosition.endX - menuPosition.startX > 3 && menuPosition.endY - menuPosition.startY > 3"
  98. :style="{
  99. position: 'absolute',
  100. top: `${menuPosition.startY}px`,
  101. left: `${menuPosition.startX}px`,
  102. width: `${menuPosition.endX - menuPosition.startX}px`,
  103. height: `${menuPosition.endY - menuPosition.startY}px`,
  104. border: '2px solid #165DFF',
  105. zIndex: '9',
  106. }"
  107. ></div>
  108. <!-- 选中文本的工具栏 -->
  109. <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
  110. <template v-if="canRemark">
  111. <!-- <span class="button" @click="handleMenuItemClick($event, 'tool')">
  112. <SvgIcon icon-class="sidebar-pushpin" size="14" /> 添加批注
  113. </span> -->
  114. </template>
  115. <template v-else>
  116. <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
  117. <span class="line"></span>
  118. <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
  119. <span class="line"></span>
  120. <span class="button" @click="setTranslate"> <SvgIcon icon-class="sidebar-translate" size="14" /> 翻译</span>
  121. <!-- <span class="line"></span>
  122. <span class="button" @click="setFeedback"> <SvgIcon icon-class="sidebar-feedback" size="14" /> 用户反馈</span> -->
  123. </template>
  124. </div>
  125. <template v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj['WHOLE']">
  126. <el-popover
  127. v-for="(items, indexs) in componentRemarkObj['WHOLE']"
  128. :key="'menu-remark-info' + indexs"
  129. placement="bottom"
  130. trigger="click"
  131. popper-class="menu-remark-info"
  132. >
  133. <div v-html="items.content"></div>
  134. <template v-if="items.file_list.length > 0">
  135. <div v-for="(item, index) in items.file_list" :key="indexs + '-' + index" class="remark-file-item">
  136. <SvgIcon :icon-class="item.icon_type" />
  137. <span class="file-item-name">{{ item.file_name }}</span>
  138. <SvgIcon icon-class="uploadPreview" @click="viewDialog(item)" />
  139. <SvgIcon icon-class="download" @click="downLoad(item)" />
  140. </div>
  141. </template>
  142. <template #reference>
  143. <div
  144. v-if="items.position_br_x - items.position_x > 3 && items.position_br_y - items.position_y > 3"
  145. slot="reference"
  146. :style="{
  147. position: 'absolute',
  148. top: `${items.position_y}px`,
  149. left: `${items.position_x}px`,
  150. width: `${items.position_br_x - items.position_x}px`,
  151. height: `${items.position_br_y - items.position_y}px`,
  152. border: '2px solid #165DFF',
  153. zIndex: 10,
  154. }"
  155. ></div>
  156. </template>
  157. </el-popover>
  158. </template>
  159. <el-dialog
  160. v-if="visible"
  161. :visible.sync="visible"
  162. :show-close="true"
  163. :close-on-click-modal="true"
  164. :modal-append-to-body="true"
  165. :append-to-body="true"
  166. :lock-scroll="true"
  167. :width="'80%'"
  168. top="0"
  169. >
  170. <iframe v-if="visible" :src="newpath" width="100%" :height="iframeHeight" frameborder="0"></iframe>
  171. </el-dialog>
  172. </div>
  173. </template>
  174. <script>
  175. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  176. import { getToken, getConfig } from '@/utils/auth';
  177. import { buildCoursewareStyle } from '@/views/book/courseware/preview/common/utils/coursewareStyle';
  178. import _ from 'lodash';
  179. const Base64 = require('js-base64').Base64;
  180. export default {
  181. name: 'CoursewarePreview',
  182. provide() {
  183. return {
  184. getDragStatus: () => false,
  185. bookInfo: this.bookInfo,
  186. getCoursewareId: () => this.courseware_id,
  187. };
  188. },
  189. props: {
  190. data: {
  191. type: Object,
  192. default: () => ({}),
  193. },
  194. coursewareId: {
  195. type: String,
  196. default: '',
  197. },
  198. background: {
  199. type: Object,
  200. default: () => ({}),
  201. },
  202. componentList: {
  203. type: Array,
  204. required: true,
  205. },
  206. canRemark: {
  207. type: Boolean,
  208. default: false,
  209. },
  210. showRemark: {
  211. type: Boolean,
  212. default: false,
  213. },
  214. componentRemarkObj: {
  215. type: Object,
  216. default: () => ({}),
  217. },
  218. groupRowList: {
  219. type: Array,
  220. default: () => [],
  221. },
  222. isShowGroup: {
  223. type: Boolean,
  224. default: false,
  225. },
  226. groupShowAll: {
  227. type: Boolean,
  228. default: true,
  229. },
  230. project: {
  231. type: Object,
  232. default: () => ({}),
  233. },
  234. type: {
  235. type: String,
  236. default: '',
  237. },
  238. isMobile: {
  239. type: Boolean,
  240. default: false,
  241. },
  242. rowCheckedList: {
  243. type: Object,
  244. default: () => ({}),
  245. },
  246. },
  247. data() {
  248. return {
  249. previewComponentList,
  250. courseware_id: this.coursewareId,
  251. bookInfo: {
  252. theme_color: '',
  253. },
  254. showMenu: false,
  255. divPosition: {
  256. left: 0,
  257. top: 0,
  258. }, // courserware盒子原始距离页面顶部和左边的距离
  259. menuPosition: { x: 0, y: 0, select_node: '', startX: null, startY: null, endX: null, endY: null }, // 用于存储菜单的位置
  260. componentId: '', // 添加批注的组件id
  261. rowCheckList: {},
  262. showToolbar: false,
  263. contentmenu: {
  264. left: 0,
  265. top: 0,
  266. },
  267. selectedInfo: null,
  268. selectHandleInfo: null,
  269. curSelectId: '', // 当前选中组件id
  270. isSelecting: false, // 是否开始框选内容
  271. file_preview_url: getConfig() ? getConfig().doc_preview_service_address : '',
  272. visible: false,
  273. newpath: '',
  274. iframeHeight: `${window.innerHeight - 100}px`,
  275. visible_id: this.$route.query?.visible_id || '', // 可见组件 id
  276. };
  277. },
  278. computed: {
  279. // 计算课件背景样式
  280. computedCoursewareStyle() {
  281. return buildCoursewareStyle(this.background, 'courseware');
  282. },
  283. },
  284. watch: {
  285. groupRowList: {
  286. handler(val) {
  287. if (!val) return;
  288. this.rowCheckList = val
  289. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  290. .reduce((acc, row) => {
  291. acc[row.row_id] = false;
  292. return acc;
  293. }, {});
  294. },
  295. },
  296. coursewareId: {
  297. handler(val) {
  298. this.courseware_id = val;
  299. },
  300. },
  301. rowCheckedList: {
  302. handler(val) {
  303. this.rowCheckList = val;
  304. },
  305. immediate: true,
  306. deep: true,
  307. },
  308. },
  309. mounted() {
  310. const element = this.$refs.courseware;
  311. const rect = element.getBoundingClientRect();
  312. this.divPosition = {
  313. left: rect.left,
  314. top: rect.top,
  315. };
  316. window.addEventListener('mousedown', this.handleMouseDown);
  317. this.handleScrollVisibleComponent();
  318. },
  319. beforeDestroy() {
  320. window.removeEventListener('mousedown', this.handleMouseDown);
  321. },
  322. methods: {
  323. /**
  324. * 滚动到 visible_id 的组件
  325. */
  326. async handleScrollVisibleComponent() {
  327. if (!this.visible_id) return;
  328. await this.$nextTick();
  329. const target = await this.findChildComponentByKey(this.visible_id);
  330. if (target && target.$el) {
  331. target.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  332. }
  333. },
  334. /**
  335. * 处理选中组件事件
  336. * 这个事件只在编辑预览模式下触发
  337. * @param {string} component_id 组件在课件内部的 ID
  338. */
  339. selectedComponent(component_id) {
  340. if (this.type !== 'edit_preview') return;
  341. this.curSelectId = component_id;
  342. const selectedComponent = this.componentList.find((item) => item.component_id === component_id);
  343. this.$emit('selectedComponent', { courseware_id: selectedComponent?.courseware_id, component_id });
  344. },
  345. /**
  346. * 计算组件内容
  347. * @param {string} id 组件id
  348. * @returns {string} 组件内容
  349. */
  350. computedColContent(id) {
  351. if (!id) return '';
  352. return this.componentList.find((item) => item.component_id === id)?.content || '';
  353. },
  354. /**
  355. * 计算组件背景
  356. * @param {string} id 组件id
  357. * @returns {object} 组件背景样式
  358. */
  359. computedColBackground(id) {
  360. if (!id) return {};
  361. const background = this.componentList.find((item) => item.component_id === id)?.background;
  362. return background ? JSON.parse(background) : {};
  363. },
  364. getMultipleColStyle(i) {
  365. let row = this.data.row_list[i];
  366. let col = row.col_list;
  367. if (col.length <= 1) {
  368. return {
  369. gridTemplateColumns: '100fr',
  370. };
  371. }
  372. let gridTemplateColumns = row.width_list.join(' ');
  373. return {
  374. gridAutoFlow: 'column',
  375. gridTemplateColumns,
  376. gridTemplateRows: 'auto',
  377. };
  378. },
  379. /**
  380. * 计算行的可见性
  381. * @params {string} rowId 行的ID
  382. * @return {boolean} 行是否可见
  383. */
  384. computedRowVisibility(rowId) {
  385. if (this.groupShowAll) return true;
  386. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  387. if (is_pre_same_group) {
  388. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  389. if (index === -1) return false;
  390. for (let i = index - 1; i >= 0; i--) {
  391. if (!this.groupRowList[i].is_pre_same_group) {
  392. return this.rowCheckList[this.groupRowList[i].row_id];
  393. }
  394. }
  395. return false;
  396. }
  397. return this.rowCheckList[row_id];
  398. },
  399. /**
  400. * 计算选中分组行的课件信息
  401. * @returns {object} 选中分组行的课件信息
  402. */
  403. computedSelectedGroupCoursewareInfo() {
  404. if (Object.keys(this.rowCheckList).length === 0) {
  405. return {};
  406. }
  407. // 根据 rowCheckList 过滤出选中的行,获取这些行的组件信息
  408. let coursewareInfo = structuredClone(this.data);
  409. coursewareInfo.row_list = coursewareInfo.row_list.filter((row) => {
  410. let groupRow = this.groupRowList.find(({ row_id }) => row_id === row.row_id);
  411. if (!groupRow.is_pre_same_group) {
  412. return this.rowCheckList[groupRow.row_id];
  413. }
  414. const index = this.groupRowList.findIndex(({ row_id }) => row_id === row.row_id);
  415. if (index === -1) return false;
  416. for (let i = index - 1; i >= 0; i--) {
  417. if (!this.groupRowList[i].is_pre_same_group) {
  418. return this.rowCheckList[this.groupRowList[i].row_id];
  419. }
  420. }
  421. return false;
  422. });
  423. // 获取选中行的所有组件id列表
  424. let component_id_list = coursewareInfo.row_list.flatMap((row) =>
  425. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  426. );
  427. // 获取选中行的分组列表描述
  428. let content_group_row_list = this.groupRowList.filter(({ row_id, is_pre_same_group }) => {
  429. if (!is_pre_same_group) {
  430. return this.rowCheckList[row_id];
  431. }
  432. const index = this.groupRowList.findIndex(({ row_id: id }) => id === row_id);
  433. if (index === -1) return false;
  434. for (let i = index - 1; i >= 0; i--) {
  435. if (!this.groupRowList[i].is_pre_same_group) {
  436. return this.rowCheckList[this.groupRowList[i].row_id];
  437. }
  438. }
  439. return false;
  440. });
  441. let groupIdList = _.cloneDeep(content_group_row_list);
  442. let groupList = [];
  443. // 通过判断 is_pre_same_group 将组合并
  444. for (let i = 0; i < groupIdList.length; i++) {
  445. if (groupIdList[i].is_pre_same_group) {
  446. groupList[groupList.length - 1].row_id_list.push(groupIdList[i].row_id);
  447. } else {
  448. groupList.push({
  449. name: '',
  450. row_id_list: [groupIdList[i].row_id],
  451. component_id_list: [],
  452. });
  453. }
  454. }
  455. // 通过合并后的分组,获取对应的组件 id 和分组名称
  456. groupList.forEach(({ row_id_list, component_id_list }, i) => {
  457. row_id_list.forEach((row_id, j) => {
  458. let row = this.data.row_list.find((row) => {
  459. return row.row_id === row_id;
  460. });
  461. // 当前行所有组件id列表
  462. let gridIdList = row.col_list.map((col) => col.grid_list.map((grid) => grid.id)).flat();
  463. component_id_list.push(...gridIdList);
  464. // 查找每组第一行中第一个包含 describe、label 或 stem 的组件
  465. if (j === 0) {
  466. let findKey = '';
  467. let findType = '';
  468. row.col_list.some((col) => {
  469. const findItem = col.grid_list.find(({ type }) => {
  470. return ['describe', 'label', 'stem'].includes(type);
  471. });
  472. if (findItem) {
  473. findKey = findItem.id;
  474. findType = findItem.type;
  475. return true;
  476. }
  477. });
  478. let groupName = `组${i + 1}`;
  479. // 如果有标签类组件,获取对应名称
  480. if (findKey) {
  481. let item = this.$refs.preview.find(
  482. (child) => child.$el && child.$el.dataset && child.$el.dataset.id === findKey,
  483. );
  484. if (['describe', 'stem'].includes(findType)) {
  485. groupName = item.data.content.replace(/<[^>]+>/g, '');
  486. } else if (findType === 'label') {
  487. groupName = item.data.dynamicTags.map((tag) => tag.text).join(', ');
  488. }
  489. }
  490. groupList[i].name = groupName;
  491. }
  492. });
  493. });
  494. return {
  495. content: JSON.stringify(coursewareInfo),
  496. component_id_list,
  497. content_group_row_list: JSON.stringify(content_group_row_list),
  498. content_group_component_list: JSON.stringify(groupList),
  499. };
  500. },
  501. /**
  502. * 保存课节为样式模板
  503. * @param {object} param0 保存样式模板所需参数
  504. * @param {string} param0.courseware_id 课件id
  505. * @param {'true' | 'false'} param0.is_select_part_courseware_mode 是否选择部分课件模式
  506. * @param {string[]} param0.component_id_list 组件id列表
  507. */
  508. async saveCoursewareStyleTemplate({ courseware_id, is_select_part_courseware_mode, component_id_list = [] }) {
  509. const allComponentIds = this.data.row_list.flatMap((row) =>
  510. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  511. );
  512. // 如果是选择部分课件模式,则使用传入的 component_id_list,否则使用所有组件id列表
  513. const idList = is_select_part_courseware_mode === 'true' ? component_id_list : allComponentIds;
  514. const saveTasks = [];
  515. // 遍历组件id列表,找到对应组件并调用其保存样式模板方法
  516. for (const id of idList) {
  517. const component = await this.findChildComponentByKey(id);
  518. if (component && typeof component.saveStyleTemplate === 'function') {
  519. saveTasks.push(component.saveStyleTemplate(courseware_id));
  520. }
  521. }
  522. await Promise.all(saveTasks);
  523. this.$message.success('已保存为样式模板');
  524. },
  525. /**
  526. * 清空行选择列表
  527. */
  528. clearRowCheckList() {
  529. this.rowCheckList = {};
  530. },
  531. /**
  532. * 分割整数为多个 1的倍数
  533. * @param {number} num
  534. * @param {number} parts
  535. */
  536. splitInteger(num, parts) {
  537. let base = Math.floor(num / parts);
  538. let arr = Array(parts).fill(base);
  539. let remainder = num - base * parts;
  540. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  541. arr[i] += 1;
  542. remainder -= 1;
  543. }
  544. return arr;
  545. },
  546. /**
  547. * 计算列的样式
  548. * @param {Object} col 列对象
  549. * @returns {Object} 列的样式对象
  550. */
  551. computedColStyle(col) {
  552. const grid = col.grid_list || [];
  553. if (grid.length === 0) {
  554. return {
  555. width: col.width,
  556. gridTemplateAreas: '',
  557. gridTemplateColumns: '',
  558. gridTemplateRows: '',
  559. };
  560. }
  561. // 先按 row 分组,避免后续重复 filter 扫描
  562. const rowMap = new Map();
  563. grid.forEach((item) => {
  564. if (!rowMap.has(item.row)) {
  565. rowMap.set(item.row, []);
  566. }
  567. rowMap.get(item.row).push(item);
  568. });
  569. let maxCol = 0;
  570. let curMaxRow = 0;
  571. rowMap.forEach((items, row) => {
  572. if (items.length > maxCol) {
  573. maxCol = items.length;
  574. curMaxRow = row;
  575. }
  576. });
  577. // 计算 grid_template_areas
  578. let gridTemplateAreas = '';
  579. rowMap.forEach((items, row) => {
  580. let rowAreas = [];
  581. if (row === curMaxRow) {
  582. rowAreas = items.map((item) => item.grid_area);
  583. } else {
  584. const needNum = maxCol - items.length;
  585. if (items.length === 1) {
  586. rowAreas = Array(needNum + 1).fill(items[0].grid_area);
  587. } else {
  588. const splitArr = this.splitInteger(needNum, items.length);
  589. rowAreas = items.flatMap((item, index) => Array(splitArr[index] + 1).fill(item.grid_area));
  590. }
  591. }
  592. gridTemplateAreas += `'${rowAreas.join(' ')}' `;
  593. });
  594. // 计算 grid_template_columns
  595. const maxRowItems = rowMap.get(curMaxRow) || [];
  596. const gridTemplateColumns = maxRowItems.length ? `${maxRowItems.map((item) => item.width).join(' ')} ` : '';
  597. // 计算 grid_template_rows
  598. const previewById = new Map();
  599. (this.$refs.preview || []).forEach((child) => {
  600. const id = child?.$el?.dataset?.id;
  601. if (id) {
  602. previewById.set(id, child);
  603. }
  604. });
  605. const hasOperationById = (id) => {
  606. const component = previewById.get(id);
  607. if (!component || !component.$el) return false;
  608. const operation = component.$el.querySelector('.operation');
  609. // 存在 .operation 且真实占位(有高度)时才需要额外预留高度,
  610. // 避免 operation 被隐藏/未渲染时多加 48px
  611. return Boolean(operation && operation.offsetHeight > 0);
  612. };
  613. const toNumberHeight = (height) => {
  614. const num = Number(String(height).replace('px', ''));
  615. return Number.isFinite(num) ? num : NaN;
  616. };
  617. let gridTemplateRows = '';
  618. rowMap.forEach((items) => {
  619. if (items.length === 1) {
  620. const current = items[0];
  621. if (current.height === 'auto') {
  622. gridTemplateRows += 'auto ';
  623. return;
  624. }
  625. let baseHeight = toNumberHeight(current.height);
  626. if (Number.isNaN(baseHeight)) {
  627. gridTemplateRows += `${current.height} `;
  628. return;
  629. }
  630. if (hasOperationById(current.id)) {
  631. baseHeight += 48;
  632. }
  633. gridTemplateRows += `${baseHeight}px `;
  634. return;
  635. }
  636. const nonAutoItems = items.filter((item) => item.height !== 'auto');
  637. if (nonAutoItems.length === 0) {
  638. gridTemplateRows += 'auto ';
  639. return;
  640. }
  641. let maxItem = null;
  642. let maxHeight = 0;
  643. nonAutoItems.forEach((item) => {
  644. const current = toNumberHeight(item.height);
  645. if (!Number.isNaN(current) && current > maxHeight) {
  646. maxHeight = current;
  647. maxItem = item;
  648. }
  649. });
  650. if (maxItem && hasOperationById(maxItem.id)) {
  651. maxHeight += 48;
  652. }
  653. gridTemplateRows += `${maxHeight}px `;
  654. });
  655. return {
  656. width: col.width,
  657. gridTemplateAreas,
  658. gridTemplateColumns,
  659. gridTemplateRows,
  660. };
  661. },
  662. handleContextMenu(event, id) {
  663. if (this.canRemark) {
  664. event.preventDefault(); // 阻止默认的上下文菜单显示
  665. this.menuPosition = {
  666. x: event.clientX - this.divPosition.left,
  667. y: event.clientY - this.divPosition.top,
  668. }; // 设置菜单位置
  669. this.componentId = id;
  670. this.$emit('computeScroll');
  671. }
  672. },
  673. handleResult(top, left, select_node) {
  674. this.menuPosition.x += left;
  675. this.menuPosition.y += top;
  676. this.menuPosition.select_node = select_node;
  677. // 设置菜单位置
  678. this.showMenu = true; // 显示菜单
  679. },
  680. handleMenuItemClick(event, type) {
  681. this.showMenu = false; // 隐藏菜单
  682. let text = '';
  683. if (type && type === 'tool') {
  684. let info = this.selectHandleInfo;
  685. this.menuPosition = {
  686. x: event.clientX - this.divPosition.left,
  687. y: event.clientY - this.divPosition.top - 20,
  688. }; // 设置菜单位置
  689. this.componentId = info.blockId;
  690. text = info.text;
  691. this.showMenu = false;
  692. } else {
  693. this.componentId = this.getElementFromPoint(
  694. (this.menuPosition.startX + this.menuPosition.endX) / 2,
  695. (this.menuPosition.startY + this.menuPosition.endY) / 2,
  696. );
  697. }
  698. this.$emit('computeScroll');
  699. setTimeout(() => {
  700. this.$emit(
  701. 'addRemark',
  702. this.menuPosition.select_node,
  703. this.menuPosition.startX,
  704. this.menuPosition.startY,
  705. this.menuPosition.endX,
  706. this.menuPosition.endY,
  707. this.componentId,
  708. text,
  709. );
  710. }, 10);
  711. },
  712. handleMouseDown(event) {
  713. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  714. // 0 表示左键
  715. this.showMenu = false;
  716. }
  717. },
  718. /**
  719. * 查找子组件
  720. * @param {string} id 组件的唯一标识符
  721. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  722. */
  723. async findChildComponentByKey(id) {
  724. await this.$nextTick();
  725. if (!this.$refs.preview) {
  726. // 最多等待 1000ms
  727. for (let i = 0; i < 20; i++) {
  728. await this.$nextTick();
  729. await new Promise((resolve) => setTimeout(resolve, 50));
  730. if (this.$refs.preview) break;
  731. }
  732. }
  733. // 如果等待后还是不存在,那就返回null
  734. if (!this.$refs.preview) {
  735. console.error('$refs.preview 不存在');
  736. return null;
  737. }
  738. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  739. },
  740. /**
  741. * 模拟回答
  742. * @param {boolean} isJudgingRightWrong 是否判断对错
  743. * @param {boolean} isShowRightAnswer 是否显示正确答案
  744. * @param {boolean} disabled 是否禁用
  745. */
  746. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
  747. this.$refs.preview.forEach((item) => {
  748. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
  749. });
  750. },
  751. /**
  752. * 处理组件高度变化事件
  753. * @param {string} id 组件id
  754. * @param {string} newHeight 组件的新高度
  755. */
  756. handleHeightChange(id, newHeight) {
  757. this.data.row_list.forEach((row) => {
  758. row.col_list.forEach((col) => {
  759. col.grid_list.forEach((grid) => {
  760. if (grid.id === id) {
  761. grid.height = newHeight;
  762. }
  763. });
  764. });
  765. });
  766. },
  767. // 处理选中文本
  768. handleTextSelection() {
  769. this.showToolbar = false;
  770. setTimeout(() => {
  771. const selection = window.getSelection();
  772. if (selection.toString().trim() === '') return null;
  773. const selectedText = selection.toString().trim();
  774. const range = selection.getRangeAt(0);
  775. this.selectedInfo = {
  776. text: selectedText,
  777. range,
  778. };
  779. let selectHandleInfo = this.getSelectionInfo();
  780. if (!selectHandleInfo || !selectHandleInfo.text) return;
  781. this.selectHandleInfo = selectHandleInfo;
  782. if (!this.canRemark) this.showToolbar = true;
  783. const selectRect = range.getBoundingClientRect();
  784. this.contentmenu = {
  785. position: 'fixed',
  786. left: `${Math.round(selectRect.left + selectRect.width / 2 - 63)}px`,
  787. top: `${Math.round(selectRect.bottom + 5)}px`,
  788. };
  789. }, 100);
  790. if (this.canRemark) {
  791. this.endSelection();
  792. }
  793. },
  794. // 笔记
  795. setNote() {
  796. this.showToolbar = false;
  797. this.oldRichData = {};
  798. let info = this.selectHandleInfo;
  799. if (!info) return;
  800. info.coursewareId = this.courseware_id;
  801. this.$emit('editNote', info);
  802. this.selectedInfo = null;
  803. },
  804. // 加入收藏
  805. setCollect() {
  806. this.showToolbar = false;
  807. let info = this.selectHandleInfo;
  808. if (!info) return;
  809. info.coursewareId = this.courseware_id;
  810. this.$emit('saveCollect', info);
  811. this.selectedInfo = null;
  812. },
  813. // 翻译
  814. setTranslate() {
  815. this.showToolbar = false;
  816. let info = this.selectHandleInfo;
  817. if (!info) return;
  818. info.coursewareId = this.courseware_id;
  819. this.$emit('getTranslate', info);
  820. this.selectedInfo = null;
  821. },
  822. // 反馈
  823. setFeedback() {
  824. this.showToolbar = false;
  825. this.oldRichData = {};
  826. let info = this.selectHandleInfo;
  827. if (!info) return;
  828. info.coursewareId = this.courseware_id;
  829. this.$emit('editFeedback', info);
  830. this.selectedInfo = null;
  831. },
  832. // 定位
  833. handleLocation(item) {
  834. this.scrollToDataId(item.blockId);
  835. },
  836. getSelectionInfo() {
  837. if (!this.selectedInfo) return;
  838. const range = this.selectedInfo.range;
  839. let selectedText = this.selectedInfo.text;
  840. if (!selectedText) return null;
  841. let commonAncestor = range.commonAncestorContainer;
  842. if (commonAncestor.nodeType === Node.TEXT_NODE) {
  843. commonAncestor = commonAncestor.parentNode;
  844. }
  845. const blockElement = commonAncestor.closest('[data-id]');
  846. if (!blockElement) return null;
  847. const blockId = blockElement.dataset.id;
  848. // 获取所有汉字元素
  849. const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
  850. // 构建包含位置信息的文本数组
  851. const textFragments = Array.from(charElements)
  852. .map((el, index) => {
  853. let text = '';
  854. if (el.classList.contains('rich-text')) {
  855. const pElements = Array.from(el.querySelectorAll('p'));
  856. text = pElements.map((p) => p.textContent.trim()).join('');
  857. } else if (el.classList.contains('NNPE-chs')) {
  858. const spanElements = Array.from(el.querySelectorAll('span'));
  859. spanElements.push(el);
  860. text = spanElements.map((span) => span.textContent.trim()).join('');
  861. } else {
  862. text = el.textContent.trim();
  863. }
  864. // 过滤掉拼音和空文本
  865. if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
  866. return { text: '', element: el, index };
  867. }
  868. return { text, element: el, index };
  869. })
  870. .filter((fragment) => fragment.text);
  871. // 获取完整的纯文本
  872. const fullText = textFragments.map((f) => f.text).join('');
  873. // 清理选中文本
  874. let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
  875. cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
  876. if (!cleanSelectedText) return null;
  877. // 方案1A:使用Range的边界点精确定位
  878. try {
  879. const startContainer = range.startContainer;
  880. const startOffset = range.startOffset;
  881. // 找到选择开始的元素在textFragments中的位置
  882. let startFragmentIndex = -1;
  883. let cumulativeLength = 0;
  884. let startIndexInFullText = -1;
  885. for (let i = 0; i < textFragments.length; i++) {
  886. const fragment = textFragments[i];
  887. // 检查这个元素是否包含选择起点
  888. if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
  889. // 计算在这个元素内的起始位置
  890. if (startContainer.nodeType === Node.TEXT_NODE) {
  891. // 如果是文本节点,需要计算在父元素中的偏移
  892. const elementText = fragment.text;
  893. startFragmentIndex = i;
  894. startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
  895. break;
  896. } else {
  897. // 如果是元素节点,从0开始
  898. startFragmentIndex = i;
  899. startIndexInFullText = cumulativeLength;
  900. break;
  901. }
  902. }
  903. cumulativeLength += fragment.text.length;
  904. }
  905. if (startIndexInFullText === -1) {
  906. // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
  907. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  908. }
  909. const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
  910. return {
  911. blockId,
  912. text: cleanSelectedText,
  913. startIndex: startIndexInFullText,
  914. endIndex: endIndexInFullText,
  915. fullText,
  916. };
  917. } catch (error) {
  918. console.warn('精确位置计算失败,使用备选方案:', error);
  919. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  920. }
  921. },
  922. // 备选方案:基于DOM位置的智能匹配
  923. fallbackToTextMatch(fullText, selectedText, range, textFragments) {
  924. // 获取选择范围的近似位置
  925. const rangeRect = range.getBoundingClientRect();
  926. // 找到最接近选择中心的文本片段
  927. let closestFragment = null;
  928. let minDistance = Infinity;
  929. textFragments.forEach((fragment) => {
  930. const rect = fragment.element.getBoundingClientRect();
  931. if (rect.width > 0 && rect.height > 0) {
  932. // 确保元素可见
  933. const centerX = rect.left + rect.width / 2;
  934. const centerY = rect.top + rect.height / 2;
  935. const rangeCenterX = rangeRect.left + rangeRect.width / 2;
  936. const rangeCenterY = rangeRect.top + rangeRect.height / 2;
  937. const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
  938. if (distance < minDistance) {
  939. minDistance = distance;
  940. closestFragment = fragment;
  941. }
  942. }
  943. });
  944. if (closestFragment) {
  945. // 从最近的片段开始向前后搜索匹配
  946. const fragmentIndex = textFragments.indexOf(closestFragment);
  947. let cumulativeLength = 0;
  948. // 计算到当前片段的累计长度
  949. for (let i = 0; i < fragmentIndex; i++) {
  950. cumulativeLength += textFragments[i].text.length;
  951. }
  952. // 在当前片段附近搜索匹配
  953. const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
  954. const searchEnd = Math.min(
  955. fullText.length,
  956. cumulativeLength + closestFragment.text.length + selectedText.length * 3,
  957. );
  958. const searchArea = fullText.substring(searchStart, searchEnd);
  959. const localIndex = searchArea.indexOf(selectedText);
  960. if (localIndex !== -1) {
  961. return {
  962. startIndex: searchStart + localIndex,
  963. endIndex: searchStart + localIndex + selectedText.length,
  964. text: selectedText,
  965. fullText,
  966. };
  967. }
  968. }
  969. // 最终回退:使用所有匹配位置,选择最合理的一个
  970. const allMatches = [];
  971. let searchIndex = 0;
  972. while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
  973. allMatches.push(searchIndex);
  974. searchIndex += selectedText.length;
  975. }
  976. if (allMatches.length === 1) {
  977. return {
  978. startIndex: allMatches[0],
  979. endIndex: allMatches[0] + selectedText.length,
  980. text: selectedText,
  981. fullText,
  982. };
  983. } else if (allMatches.length > 1) {
  984. // 如果有多个匹配,选择位置最接近选择中心的
  985. if (closestFragment) {
  986. let cumulativeLength = 0;
  987. let fragmentStartIndex = 0;
  988. for (let i = 0; i < textFragments.length; i++) {
  989. if (textFragments[i] === closestFragment) {
  990. fragmentStartIndex = cumulativeLength;
  991. break;
  992. }
  993. cumulativeLength += textFragments[i].text.length;
  994. }
  995. // 选择最接近当前片段起始位置的匹配
  996. const bestMatch = allMatches.reduce((best, current) => {
  997. return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
  998. });
  999. return {
  1000. startIndex: bestMatch,
  1001. endIndex: bestMatch + selectedText.length,
  1002. text: selectedText,
  1003. fullText,
  1004. };
  1005. }
  1006. }
  1007. return null;
  1008. },
  1009. /**
  1010. * 滚动到指定data-id的元素
  1011. * @param {string} dataId 元素的data-id属性值
  1012. * @param {number} offset 偏移量
  1013. */
  1014. scrollToDataId(dataId, offset) {
  1015. let _offset = offset;
  1016. if (!_offset) _offset = 0;
  1017. const element = document.querySelector(`div[data-id="${dataId}"]`);
  1018. if (element) {
  1019. element.scrollIntoView({
  1020. behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
  1021. block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
  1022. inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
  1023. });
  1024. }
  1025. },
  1026. startSelection(event) {
  1027. if (this.canRemark) {
  1028. this.isSelecting = true;
  1029. let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
  1030. this.menuPosition.startX = event.clientX - clientRect.left;
  1031. this.menuPosition.startY = event.clientY - clientRect.top;
  1032. this.menuPosition.endX = null;
  1033. this.menuPosition.endY = null;
  1034. }
  1035. },
  1036. updateSelection(event) {
  1037. if (!this.isSelecting || !this.canRemark) return;
  1038. let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
  1039. this.menuPosition.endX = event.clientX - clientRect.left;
  1040. this.menuPosition.endY = event.clientY - clientRect.top;
  1041. },
  1042. endSelection() {
  1043. this.isSelecting = false;
  1044. if (this.menuPosition.startX === this.menuPosition.endX || !this.menuPosition.endX || !this.canRemark) return;
  1045. const width = this.menuPosition.endX - this.menuPosition.startX;
  1046. const height = this.menuPosition.endY - this.menuPosition.startY;
  1047. const x =
  1048. this.menuPosition.endX > this.menuPosition.startX
  1049. ? `${this.menuPosition.startX}px`
  1050. : `${this.menuPosition.endX}px`;
  1051. const y =
  1052. this.menuPosition.endY > this.menuPosition.startY
  1053. ? `${this.menuPosition.startY}px`
  1054. : `${this.menuPosition.endY}px`;
  1055. if (width > 3 && height > 3) {
  1056. this.handleMenuItemClick();
  1057. } else {
  1058. this.resetRemark();
  1059. }
  1060. },
  1061. // 重置框选数据
  1062. resetRemark() {
  1063. this.menuPosition = { x: 0, y: 0, select_node: '', startX: null, startY: null, endX: null, endY: null };
  1064. },
  1065. // 下载文件
  1066. downLoad(file) {
  1067. let userInfo = getToken();
  1068. let AccessToken = '';
  1069. if (userInfo) {
  1070. AccessToken = userInfo.access_token;
  1071. }
  1072. let FileID = file.file_id;
  1073. let data = {
  1074. AccessToken,
  1075. FileID,
  1076. };
  1077. location.href = `${process.env.VUE_APP_EEP}/FileServer/WebFileDownload?AccessToken=${data.AccessToken}&FileID=${data.FileID}`;
  1078. },
  1079. // 预览
  1080. viewDialog(file) {
  1081. this.newpath = `${this.file_preview_url}onlinePreview?url=${Base64.encode(file.file_url)}`;
  1082. this.visible = true;
  1083. },
  1084. /**
  1085. * 根据x,y坐标获取组件id,x,y坐标相对于.courserware元素
  1086. * @param {number} x x坐标
  1087. * @param {number} y y坐标
  1088. * @return {string|null} 组件id,如果没有找到则返回null
  1089. */
  1090. getElementFromPoint(x, y) {
  1091. const courserwareRect = this.$el.getBoundingClientRect();
  1092. const absoluteX = courserwareRect.left + x;
  1093. const absoluteY = courserwareRect.top + y;
  1094. let el = document.elementFromPoint(absoluteX, absoluteY);
  1095. // 向上查找,直到找到具有 data-id 属性和 grid 类的元素
  1096. while (el && (!el.dataset.id || !el.classList.contains('grid'))) {
  1097. el = el.parentElement;
  1098. }
  1099. return el ? el.dataset.id : null;
  1100. },
  1101. },
  1102. };
  1103. </script>
  1104. <style lang="scss" scoped>
  1105. .courseware {
  1106. position: relative;
  1107. display: flex;
  1108. flex-direction: column;
  1109. row-gap: $component-spacing;
  1110. width: 100%;
  1111. max-width: $courseware-width;
  1112. height: 100%;
  1113. min-height: calc(100vh - 226px);
  1114. padding-top: $courseware-top-padding;
  1115. padding-bottom: $courseware-bottom-padding;
  1116. margin: 15px 0;
  1117. background-repeat: no-repeat;
  1118. border-bottom-right-radius: 12px;
  1119. border-bottom-left-radius: 12px;
  1120. &::before {
  1121. top: -15px;
  1122. }
  1123. &::after {
  1124. bottom: -15px;
  1125. }
  1126. .row {
  1127. display: grid;
  1128. gap: $component-spacing;
  1129. .col {
  1130. display: grid;
  1131. gap: $component-spacing;
  1132. // 与 PreviewEdit 保持一致:列被行高拉伸时,auto 行按内容紧凑排列,
  1133. // 避免富文本等自动高度组件被 align-content 默认的 stretch 撑高
  1134. align-content: start;
  1135. .active {
  1136. box-shadow: 0 0 6px 1px $main-hover-color;
  1137. }
  1138. }
  1139. .row-checkbox {
  1140. position: absolute;
  1141. left: -20px;
  1142. }
  1143. }
  1144. .custom-context-menu,
  1145. .remark-info {
  1146. position: absolute;
  1147. z-index: 999;
  1148. display: flex;
  1149. gap: 3px;
  1150. align-items: center;
  1151. font-size: 14px;
  1152. cursor: pointer;
  1153. }
  1154. .custom-context-menu {
  1155. padding-left: 30px;
  1156. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  1157. background-size: 24px;
  1158. }
  1159. .contentmenu {
  1160. z-index: 999;
  1161. display: flex;
  1162. column-gap: 4px;
  1163. align-items: center;
  1164. padding: 8px;
  1165. font-size: 14px;
  1166. color: #000;
  1167. background-color: #e7e7e7;
  1168. border-radius: 4px;
  1169. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
  1170. .svg-icon,
  1171. .button {
  1172. cursor: pointer;
  1173. }
  1174. .line {
  1175. min-height: 16px;
  1176. margin: 0 4px;
  1177. }
  1178. }
  1179. }
  1180. </style>
  1181. <style lang="scss">
  1182. .menu-remark-info {
  1183. min-width: 2%;
  1184. max-width: 450px;
  1185. video,
  1186. img {
  1187. max-width: 100%;
  1188. height: auto;
  1189. }
  1190. audio {
  1191. max-width: 100%;
  1192. }
  1193. }
  1194. .remark-file-item {
  1195. display: flex;
  1196. gap: 5px;
  1197. align-items: center;
  1198. padding: 3px;
  1199. margin: 5px 0;
  1200. background: #f2f3f5;
  1201. border-radius: 3px;
  1202. &-name {
  1203. flex: 1;
  1204. font-size: 12px;
  1205. word-break: break-all;
  1206. }
  1207. .svg-icon {
  1208. flex-shrink: 0;
  1209. font-size: 16px;
  1210. }
  1211. .uploadPreview,
  1212. .download {
  1213. cursor: pointer;
  1214. }
  1215. }
  1216. </style>