CoursewarePreview.vue 41 KB

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