CoursewarePreview.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961
  1. <template>
  2. <div ref="courserware" class="courserware" :style="computedCourserwareStyle()" @mouseup="handleTextSelection">
  3. <template v-for="(row, i) in data.row_list">
  4. <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
  5. <el-checkbox
  6. v-if="
  7. isShowGroup &&
  8. groupRowList.find(({ row_id, is_pre_same_group }) => row.row_id === row_id && !is_pre_same_group)
  9. "
  10. v-model="rowCheckList[row.row_id]"
  11. :class="['row-checkbox', `${row.row_id}`]"
  12. />
  13. <!-- 列 -->
  14. <template v-for="(col, j) in row.col_list">
  15. <div :key="j" :class="['col', `col-${i}-${j}`]" :style="computedColStyle(col)">
  16. <!-- 网格 -->
  17. <template v-for="(grid, k) in col.grid_list">
  18. <component
  19. :is="previewComponentList[grid.type]"
  20. :id="grid.id"
  21. :key="k"
  22. ref="preview"
  23. :content="computedColContent(grid.id)"
  24. :class="[grid.id]"
  25. :data-id="grid.id"
  26. :style="{
  27. gridArea: grid.grid_area,
  28. height: grid.height,
  29. }"
  30. @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
  31. @handleHeightChange="handleHeightChange"
  32. />
  33. <div
  34. v-if="showMenu && componentId === grid.id"
  35. :key="'menu' + grid.id + k"
  36. class="custom-context-menu"
  37. :style="{ left: menuPosition.x + 'px', top: menuPosition.y + 'px' }"
  38. @click="handleMenuItemClick"
  39. >
  40. 添加批注
  41. </div>
  42. <div
  43. v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj[grid.id]"
  44. :key="'show' + grid.id + k"
  45. >
  46. <el-popover
  47. v-for="(items, indexs) in componentRemarkObj[grid.id]"
  48. :key="indexs"
  49. placement="bottom"
  50. width="200"
  51. trigger="click"
  52. >
  53. <div v-html="items.content"></div>
  54. <template #reference>
  55. <SvgIcon
  56. slot="reference"
  57. icon-class="icon-info"
  58. size="24"
  59. class="remark-info"
  60. :style="{ left: items.position_x - 12 + 'px', top: items.position_y - 12 + 'px' }"
  61. />
  62. </template>
  63. </el-popover>
  64. </div>
  65. </template>
  66. </div>
  67. </template>
  68. </div>
  69. </template>
  70. <!-- 选中文本的工具栏 -->
  71. <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
  72. <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
  73. <span class="line"></span>
  74. <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
  75. </div>
  76. </div>
  77. </template>
  78. <script>
  79. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  80. import { SaveAnswer, GetAnswer, SaveAnswerMarking, GetAnswerMarking } from '@/api/book';
  81. export default {
  82. name: 'CoursewarePreview',
  83. provide() {
  84. return {
  85. getDragStatus: () => false,
  86. bookInfo: this.bookInfo,
  87. };
  88. },
  89. props: {
  90. data: {
  91. type: Object,
  92. default: () => ({}),
  93. },
  94. coursewareId: {
  95. type: String,
  96. default: '',
  97. },
  98. background: {
  99. type: Object,
  100. default: () => ({}),
  101. },
  102. componentList: {
  103. type: Array,
  104. required: true,
  105. },
  106. canRemark: {
  107. type: Boolean,
  108. default: false,
  109. },
  110. showRemark: {
  111. type: Boolean,
  112. default: false,
  113. },
  114. componentRemarkObj: {
  115. type: Object,
  116. default: () => ({}),
  117. },
  118. groupRowList: {
  119. type: Array,
  120. default: () => [],
  121. },
  122. isShowGroup: {
  123. type: Boolean,
  124. default: false,
  125. },
  126. groupShowAll: {
  127. type: Boolean,
  128. default: true,
  129. },
  130. project: {
  131. type: Object,
  132. default: () => ({}),
  133. },
  134. },
  135. data() {
  136. return {
  137. previewComponentList,
  138. courseware_id: this.coursewareId,
  139. bookInfo: {
  140. theme_color: '',
  141. },
  142. showMenu: false,
  143. divPosition: {
  144. left: 0,
  145. top: 0,
  146. }, // courserware盒子原始距离页面顶部和左边的距离
  147. menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
  148. componentId: '', // 添加批注的组件id
  149. rowCheckList: {},
  150. showToolbar: false,
  151. contentmenu: {
  152. left: 0,
  153. top: 0,
  154. },
  155. selectedInfo: null,
  156. selectHandleInfo: null,
  157. getAnswerLoading: false, // 是否正在加载答案
  158. };
  159. },
  160. watch: {
  161. groupRowList: {
  162. handler(val) {
  163. if (!val) return;
  164. this.rowCheckList = val
  165. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  166. .reduce((acc, row) => {
  167. acc[row.row_id] = false;
  168. return acc;
  169. }, {});
  170. },
  171. },
  172. coursewareId: {
  173. handler(val) {
  174. this.courseware_id = val;
  175. },
  176. },
  177. },
  178. mounted() {
  179. const element = this.$refs.courserware;
  180. const rect = element.getBoundingClientRect();
  181. this.divPosition = {
  182. left: rect.left,
  183. top: rect.top,
  184. };
  185. window.addEventListener('mousedown', this.handleMouseDown);
  186. },
  187. beforeDestroy() {
  188. window.removeEventListener('mousedown', this.handleMouseDown);
  189. },
  190. methods: {
  191. /**
  192. * 计算组件内容
  193. * @param {string} id 组件id
  194. * @returns {string} 组件内容
  195. */
  196. computedColContent(id) {
  197. if (!id) return '';
  198. return this.componentList.find((item) => item.component_id === id)?.content || '';
  199. },
  200. getMultipleColStyle(i) {
  201. let row = this.data.row_list[i];
  202. let col = row.col_list;
  203. if (col.length <= 1) {
  204. return {
  205. gridTemplateColumns: '100fr',
  206. };
  207. }
  208. let gridTemplateColumns = row.width_list.join(' ');
  209. return {
  210. gridAutoFlow: 'column',
  211. gridTemplateColumns,
  212. gridTemplateRows: 'auto',
  213. };
  214. },
  215. /**
  216. * 计算行的可见性
  217. * @params {string} rowId 行的ID
  218. */
  219. computedRowVisibility(rowId) {
  220. if (this.groupShowAll) return true;
  221. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  222. if (is_pre_same_group) {
  223. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  224. if (index === -1) return false;
  225. for (let i = index - 1; i >= 0; i--) {
  226. if (!this.groupRowList[i].is_pre_same_group) {
  227. return this.rowCheckList[this.groupRowList[i].row_id];
  228. }
  229. }
  230. return false;
  231. }
  232. return this.rowCheckList[row_id];
  233. },
  234. /**
  235. * 分割整数为多个 1的倍数
  236. * @param {number} num
  237. * @param {number} parts
  238. */
  239. splitInteger(num, parts) {
  240. let base = Math.floor(num / parts);
  241. let arr = Array(parts).fill(base);
  242. let remainder = num - base * parts;
  243. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  244. arr[i] += 1;
  245. remainder -= 1;
  246. }
  247. return arr;
  248. },
  249. /**
  250. * 计算列的样式
  251. * @param {Object} col 列对象
  252. * @returns {Object} 列的样式对象
  253. */
  254. computedColStyle(col) {
  255. const grid = col.grid_list;
  256. let maxCol = 0; // 最大列数
  257. let rowList = new Map();
  258. grid.forEach(({ row }) => {
  259. rowList.set(row, (rowList.get(row) || 0) + 1);
  260. });
  261. let curMaxRow = 0; // 当前数量最大 row 的值
  262. rowList.forEach((value, key) => {
  263. if (value > maxCol) {
  264. maxCol = value;
  265. curMaxRow = key;
  266. }
  267. });
  268. // 计算 grid_template_areas
  269. let gridTemplateAreas = '';
  270. let gridArr = [];
  271. grid.forEach(({ grid_area, row }) => {
  272. if (!gridArr[row - 1]) {
  273. gridArr[row - 1] = [];
  274. }
  275. if (curMaxRow === row) {
  276. gridArr[row - 1].push(`${grid_area}`);
  277. } else {
  278. let filter = grid.filter((item) => item.row === row);
  279. let find = filter.findIndex((item) => item.grid_area === grid_area);
  280. let needNum = maxCol - filter.length; // 需要的数量
  281. let str = '';
  282. if (filter.length === 1) {
  283. str = ` ${grid_area} `.repeat(needNum + 1);
  284. } else {
  285. let arr = this.splitInteger(needNum, filter.length);
  286. str = arr[find] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(arr[find] + 1);
  287. }
  288. gridArr[row - 1].push(`${str}`);
  289. }
  290. });
  291. gridArr.forEach((item) => {
  292. gridTemplateAreas += `'${item.join(' ')}' `;
  293. });
  294. // 计算 grid_template_columns
  295. let gridTemplateColumns = '';
  296. let max = { row: 0, num: 0 };
  297. grid.forEach(({ row }) => {
  298. // 计算出 row 的哪个值最多
  299. let len = grid.filter((item) => item.row === row).length;
  300. if (max.num < len) {
  301. max.num = len;
  302. max.row = row;
  303. }
  304. });
  305. grid.forEach((item) => {
  306. if (item.row === max.row) {
  307. gridTemplateColumns += `${item.width} `;
  308. }
  309. });
  310. // 计算 grid_template_rows
  311. let gridTemplateRows = '';
  312. // 将 grid 按照 row 分组
  313. let gridMap = new Map();
  314. grid.forEach((item) => {
  315. if (!gridMap.has(item.row)) {
  316. gridMap.set(item.row, []);
  317. }
  318. gridMap.get(item.row).push({ height: item.height, id: item.id });
  319. });
  320. gridMap.forEach((value) => {
  321. if (value.length === 1) {
  322. const component = this.$refs.preview?.find(
  323. (child) => child.$el && child.$el.dataset && child.$el.dataset.id === value[0].id,
  324. );
  325. const hasOperation = component && component.$el.querySelector('.operation') !== null; // 判断是否有操作按钮
  326. let height = hasOperation ? `${Number(value[0].height.replace('px', '')) + 48}px` : value[0].height;
  327. gridTemplateRows += `${height} `;
  328. } else {
  329. let isAllAuto = value.every((item) => item.height === 'auto'); // 是否全是 auto
  330. if (!isAllAuto) {
  331. // 判断 value 中height 最大的组件是那个,不包括 auto 的组件
  332. let maxHeight = 0;
  333. let maxId = '';
  334. value.forEach((item) => {
  335. if (item.height !== 'auto') {
  336. const heightNum = Number(item.height.replace('px', ''));
  337. if (heightNum > maxHeight) {
  338. maxHeight = heightNum;
  339. maxId = item.id;
  340. }
  341. }
  342. });
  343. // 判断最高组件是否有操作按钮
  344. const component = this.$refs.preview?.find(
  345. (child) => child.$el && child.$el.dataset && child.$el.dataset.id === maxId,
  346. );
  347. const hasOperation = component && component.$el.querySelector('.operation') !== null; // 判断是否有操作按钮
  348. if (hasOperation) {
  349. maxHeight += 48;
  350. }
  351. // 将 maxId 的 height 设置为 maxHeight
  352. value.forEach((item) => {
  353. if (item.id === maxId) {
  354. item.height = `${maxHeight}px`;
  355. }
  356. });
  357. }
  358. gridTemplateRows += isAllAuto
  359. ? 'auto '
  360. : `${Math.max(...value.map((item) => Number(item.height.replace('px', ''))))}px `;
  361. }
  362. });
  363. return {
  364. width: col.width,
  365. gridTemplateAreas,
  366. gridTemplateColumns,
  367. gridTemplateRows,
  368. };
  369. },
  370. /**
  371. * 计算课件背景样式
  372. * @returns {Object} 课件背景样式对象
  373. */
  374. computedCourserwareStyle() {
  375. const { background_image_url: bcImgUrl = '', background_position: pos = {} } = this.background || {};
  376. const hasNoRows = !Array.isArray(this.data?.row_list) || this.data.row_list.length === 0;
  377. const projectCover = this.project?.cover_image_file_url || '';
  378. // 优先在空行时使用背景图或项目封面
  379. const backgroundImage = hasNoRows ? bcImgUrl || projectCover : bcImgUrl;
  380. // 保护性读取位置/大小值,避免 undefined 导致字符串 "undefined%"
  381. const widthPct = typeof pos.width === 'undefined' ? '' : pos.width;
  382. const heightPct = typeof pos.height === 'undefined' ? '' : pos.height;
  383. const leftPct = typeof pos.left === 'undefined' ? '' : pos.left;
  384. const topPct = typeof pos.top === 'undefined' ? '' : pos.top;
  385. const hasBcImg = Boolean(bcImgUrl);
  386. const backgroundSize = hasBcImg ? `${widthPct}% ${heightPct}%` : hasNoRows ? 'contain' : '';
  387. const backgroundPosition = hasBcImg ? `${leftPct}% ${topPct}%` : hasNoRows ? 'center' : '';
  388. return {
  389. backgroundImage: backgroundImage ? `url(${backgroundImage})` : '',
  390. backgroundSize,
  391. backgroundPosition,
  392. };
  393. },
  394. handleContextMenu(event, id) {
  395. if (this.canRemark) {
  396. event.preventDefault(); // 阻止默认的上下文菜单显示
  397. this.menuPosition = {
  398. x: event.clientX - this.divPosition.left,
  399. y: event.clientY - this.divPosition.top,
  400. }; // 设置菜单位置
  401. this.componentId = id;
  402. this.$emit('computeScroll');
  403. }
  404. },
  405. handleResult(top, left, select_node) {
  406. this.menuPosition = {
  407. x: this.menuPosition.x + left,
  408. y: this.menuPosition.y + top,
  409. select_node,
  410. }; // 设置菜单位置
  411. this.showMenu = true; // 显示菜单
  412. },
  413. handleMenuItemClick() {
  414. this.showMenu = false; // 隐藏菜单
  415. this.$emit(
  416. 'addRemark',
  417. this.menuPosition.select_node,
  418. this.menuPosition.x,
  419. this.menuPosition.y,
  420. this.componentId,
  421. );
  422. },
  423. handleMouseDown(event) {
  424. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  425. // 0 表示左键
  426. this.showMenu = false;
  427. }
  428. },
  429. /**
  430. * 查找子组件
  431. * @param {string} id 组件的唯一标识符
  432. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  433. */
  434. async findChildComponentByKey(id) {
  435. await this.$nextTick();
  436. if (!this.$refs.preview) {
  437. // 最多等待 1000ms
  438. for (let i = 0; i < 20; i++) {
  439. await this.$nextTick();
  440. await new Promise((resolve) => setTimeout(resolve, 50));
  441. if (this.$refs.preview) break;
  442. }
  443. }
  444. // 如果等待后还是不存在,那就返回null
  445. if (!this.$refs.preview) {
  446. console.error('$refs.preview 不存在');
  447. return null;
  448. }
  449. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  450. },
  451. /**
  452. * 模拟回答
  453. * @param {boolean} isJudgingRightWrong 是否判断对错
  454. * @param {boolean} isShowRightAnswer 是否显示正确答案
  455. * @param {boolean} disabled 是否禁用
  456. */
  457. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true, answer = null) {
  458. if (this.getAnswerLoading) return;
  459. this.$refs.preview.forEach((item) => {
  460. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, answer, disabled);
  461. });
  462. },
  463. /**
  464. * 处理组件高度变化事件
  465. * @param {string} id 组件id
  466. * @param {string} newHeight 组件的新高度
  467. */
  468. handleHeightChange(id, newHeight) {
  469. this.data.row_list.forEach((row) => {
  470. row.col_list.forEach((col) => {
  471. col.grid_list.forEach((grid) => {
  472. if (grid.id === id) {
  473. grid.height = newHeight;
  474. }
  475. });
  476. });
  477. });
  478. },
  479. // 处理选中文本
  480. handleTextSelection() {
  481. this.showToolbar = false;
  482. // 延迟处理,确保选择已完成
  483. setTimeout(() => {
  484. const selection = window.getSelection();
  485. if (selection.toString().trim() === '') return null;
  486. const selectedText = selection.toString().trim();
  487. const range = selection.getRangeAt(0);
  488. this.selectedInfo = {
  489. text: selectedText,
  490. range,
  491. };
  492. let selectHandleInfo = this.getSelectionInfo();
  493. if (!selectHandleInfo || !selectHandleInfo.text) return;
  494. this.selectHandleInfo = selectHandleInfo;
  495. this.showToolbar = true;
  496. const container = document.querySelector('.courserware');
  497. const boxRect = container.getBoundingClientRect();
  498. const selectRect = range.getBoundingClientRect();
  499. this.contentmenu = {
  500. left: `${Math.round(selectRect.left - boxRect.left + selectRect.width / 2 - 63)}px`,
  501. top: `${Math.round(selectRect.top - boxRect.top + selectRect.height)}px`, // 向上偏移10px
  502. };
  503. }, 100);
  504. },
  505. // 笔记
  506. setNote() {
  507. this.showToolbar = false;
  508. this.oldRichData = {};
  509. let info = this.selectHandleInfo;
  510. if (!info) return;
  511. info.coursewareId = this.courseware_id;
  512. this.$emit('editNote', info);
  513. this.selectedInfo = null;
  514. },
  515. // 加入收藏
  516. setCollect() {
  517. this.showToolbar = false;
  518. let info = this.selectHandleInfo;
  519. if (!info) return;
  520. info.coursewareId = this.courseware_id;
  521. this.$emit('saveCollect', info);
  522. this.selectedInfo = null;
  523. },
  524. // 定位
  525. handleLocation(item) {
  526. this.scrollToDataId(item.blockId);
  527. },
  528. getSelectionInfo() {
  529. if (!this.selectedInfo) return;
  530. const range = this.selectedInfo.range;
  531. let selectedText = this.selectedInfo.text;
  532. if (!selectedText) return null;
  533. let commonAncestor = range.commonAncestorContainer;
  534. if (commonAncestor.nodeType === Node.TEXT_NODE) {
  535. commonAncestor = commonAncestor.parentNode;
  536. }
  537. const blockElement = commonAncestor.closest('[data-id]');
  538. if (!blockElement) return null;
  539. const blockId = blockElement.dataset.id;
  540. // 获取所有汉字元素
  541. const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
  542. // 构建包含位置信息的文本数组
  543. const textFragments = Array.from(charElements)
  544. .map((el, index) => {
  545. let text = '';
  546. if (el.classList.contains('rich-text')) {
  547. const pElements = Array.from(el.querySelectorAll('p'));
  548. text = pElements.map((p) => p.textContent.trim()).join('');
  549. } else if (el.classList.contains('NNPE-chs')) {
  550. const spanElements = Array.from(el.querySelectorAll('span'));
  551. spanElements.push(el);
  552. text = spanElements.map((span) => span.textContent.trim()).join('');
  553. } else {
  554. text = el.textContent.trim();
  555. }
  556. // 过滤掉拼音和空文本
  557. if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
  558. return { text: '', element: el, index };
  559. }
  560. return { text, element: el, index };
  561. })
  562. .filter((fragment) => fragment.text);
  563. // 获取完整的纯文本
  564. const fullText = textFragments.map((f) => f.text).join('');
  565. // 清理选中文本
  566. let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
  567. cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
  568. if (!cleanSelectedText) return null;
  569. // 方案1A:使用Range的边界点精确定位
  570. try {
  571. const startContainer = range.startContainer;
  572. const startOffset = range.startOffset;
  573. // 找到选择开始的元素在textFragments中的位置
  574. let startFragmentIndex = -1;
  575. let cumulativeLength = 0;
  576. let startIndexInFullText = -1;
  577. for (let i = 0; i < textFragments.length; i++) {
  578. const fragment = textFragments[i];
  579. // 检查这个元素是否包含选择起点
  580. if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
  581. // 计算在这个元素内的起始位置
  582. if (startContainer.nodeType === Node.TEXT_NODE) {
  583. // 如果是文本节点,需要计算在父元素中的偏移
  584. const elementText = fragment.text;
  585. startFragmentIndex = i;
  586. startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
  587. break;
  588. } else {
  589. // 如果是元素节点,从0开始
  590. startFragmentIndex = i;
  591. startIndexInFullText = cumulativeLength;
  592. break;
  593. }
  594. }
  595. cumulativeLength += fragment.text.length;
  596. }
  597. if (startIndexInFullText === -1) {
  598. // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
  599. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  600. }
  601. const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
  602. return {
  603. blockId,
  604. text: cleanSelectedText,
  605. startIndex: startIndexInFullText,
  606. endIndex: endIndexInFullText,
  607. fullText,
  608. };
  609. } catch (error) {
  610. console.warn('精确位置计算失败,使用备选方案:', error);
  611. return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
  612. }
  613. },
  614. // 备选方案:基于DOM位置的智能匹配
  615. fallbackToTextMatch(fullText, selectedText, range, textFragments) {
  616. // 获取选择范围的近似位置
  617. const rangeRect = range.getBoundingClientRect();
  618. // 找到最接近选择中心的文本片段
  619. let closestFragment = null;
  620. let minDistance = Infinity;
  621. textFragments.forEach((fragment) => {
  622. const rect = fragment.element.getBoundingClientRect();
  623. if (rect.width > 0 && rect.height > 0) {
  624. // 确保元素可见
  625. const centerX = rect.left + rect.width / 2;
  626. const centerY = rect.top + rect.height / 2;
  627. const rangeCenterX = rangeRect.left + rangeRect.width / 2;
  628. const rangeCenterY = rangeRect.top + rangeRect.height / 2;
  629. const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
  630. if (distance < minDistance) {
  631. minDistance = distance;
  632. closestFragment = fragment;
  633. }
  634. }
  635. });
  636. if (closestFragment) {
  637. // 从最近的片段开始向前后搜索匹配
  638. const fragmentIndex = textFragments.indexOf(closestFragment);
  639. let cumulativeLength = 0;
  640. // 计算到当前片段的累计长度
  641. for (let i = 0; i < fragmentIndex; i++) {
  642. cumulativeLength += textFragments[i].text.length;
  643. }
  644. // 在当前片段附近搜索匹配
  645. const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
  646. const searchEnd = Math.min(
  647. fullText.length,
  648. cumulativeLength + closestFragment.text.length + selectedText.length * 3,
  649. );
  650. const searchArea = fullText.substring(searchStart, searchEnd);
  651. const localIndex = searchArea.indexOf(selectedText);
  652. if (localIndex !== -1) {
  653. return {
  654. startIndex: searchStart + localIndex,
  655. endIndex: searchStart + localIndex + selectedText.length,
  656. text: selectedText,
  657. fullText,
  658. };
  659. }
  660. }
  661. // 最终回退:使用所有匹配位置,选择最合理的一个
  662. const allMatches = [];
  663. let searchIndex = 0;
  664. while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
  665. allMatches.push(searchIndex);
  666. searchIndex += selectedText.length;
  667. }
  668. if (allMatches.length === 1) {
  669. return {
  670. startIndex: allMatches[0],
  671. endIndex: allMatches[0] + selectedText.length,
  672. text: selectedText,
  673. fullText,
  674. };
  675. } else if (allMatches.length > 1) {
  676. // 如果有多个匹配,选择位置最接近选择中心的
  677. if (closestFragment) {
  678. let cumulativeLength = 0;
  679. let fragmentStartIndex = 0;
  680. for (let i = 0; i < textFragments.length; i++) {
  681. if (textFragments[i] === closestFragment) {
  682. fragmentStartIndex = cumulativeLength;
  683. break;
  684. }
  685. cumulativeLength += textFragments[i].text.length;
  686. }
  687. // 选择最接近当前片段起始位置的匹配
  688. const bestMatch = allMatches.reduce((best, current) => {
  689. return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
  690. });
  691. return {
  692. startIndex: bestMatch,
  693. endIndex: bestMatch + selectedText.length,
  694. text: selectedText,
  695. fullText,
  696. };
  697. }
  698. }
  699. return null;
  700. },
  701. /**
  702. * 滚动到指定data-id的元素
  703. * @param {string} dataId 元素的data-id属性值
  704. * @param {number} offset 偏移量
  705. */
  706. scrollToDataId(dataId, offset) {
  707. let _offset = offset;
  708. if (!_offset) _offset = 0;
  709. const element = document.querySelector(`div[data-id="${dataId}"]`);
  710. if (element) {
  711. element.scrollIntoView({
  712. behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
  713. block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
  714. inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
  715. });
  716. }
  717. },
  718. /**
  719. * 保存用户答案
  720. * @param {string} task_id 任务ID
  721. * @param {string} user_id 用户ID
  722. * @param {string} courseware_id 课件ID
  723. */
  724. saveAnswer({ task_id, user_id, courseware_id }) {
  725. let answer = {};
  726. this.$refs.preview.forEach((item) => {
  727. answer[item.id] = JSON.stringify(item.getAnswer());
  728. });
  729. SaveAnswer({
  730. task_id,
  731. user_id,
  732. courseware_id,
  733. answer: JSON.stringify(answer),
  734. }).then(() => {
  735. this.$message.success('答案保存成功');
  736. });
  737. },
  738. /**
  739. * 获取用户答案
  740. * @param {string} task_id 任务ID
  741. * @param {string} user_id 用户ID
  742. * @param {string} courseware_id 课件ID
  743. */
  744. getAnswer({ task_id, user_id, courseware_id }) {
  745. this.getAnswerLoading = true;
  746. GetAnswer({
  747. task_id,
  748. user_id,
  749. courseware_id,
  750. }).then(({ answer }) => {
  751. if (answer) {
  752. let _answer = JSON.parse(answer);
  753. this.$refs.preview.forEach((item) => {
  754. let answerItem = _answer[item.id];
  755. if (answerItem) {
  756. item.showAnswer(true, false, JSON.parse(answerItem), true);
  757. }
  758. });
  759. this.getAnswerLoading = false;
  760. }
  761. });
  762. },
  763. /**
  764. * 保存答案批改
  765. * @param {string} task_id 任务ID
  766. * @param {string} user_id 用户ID
  767. * @param {string} courseware_id 课件ID
  768. */
  769. saveAnswerMarking({ task_id, user_id, courseware_id }) {
  770. let answer_marking = {};
  771. this.$refs.preview.forEach((item) => {
  772. answer_marking[item.id] = JSON.stringify(item.getAnswerCorrect());
  773. });
  774. SaveAnswerMarking({
  775. task_id,
  776. user_id,
  777. courseware_id,
  778. answer_marking: JSON.stringify(answer_marking),
  779. }).then(() => {
  780. this.$message.success('批改保存成功');
  781. });
  782. },
  783. /**
  784. * 获取答案批改
  785. * @param {string} task_id 任务ID
  786. * @param {string} user_id 用户ID
  787. * @param {string} courseware_id 课件ID
  788. */
  789. getAnswerMarking({ task_id, user_id, courseware_id }) {
  790. GetAnswerMarking({
  791. task_id,
  792. user_id,
  793. courseware_id,
  794. }).then(({ answer_marking }) => {
  795. if (answer_marking) {
  796. let _answer_marking = JSON.parse(answer_marking);
  797. this.$refs.preview.forEach((item) => {
  798. let answerMarkingItem = _answer_marking[item.id];
  799. if (answerMarkingItem) {
  800. item.setAnswerCorrect(JSON.parse(answerMarkingItem));
  801. }
  802. });
  803. }
  804. });
  805. },
  806. },
  807. };
  808. </script>
  809. <style lang="scss" scoped>
  810. .courserware {
  811. position: relative;
  812. display: flex;
  813. flex-direction: column;
  814. row-gap: $component-spacing;
  815. width: 100%;
  816. height: 100%;
  817. min-height: calc(100vh - 226px);
  818. padding-top: $courseware-top-padding;
  819. padding-bottom: $courseware-bottom-padding;
  820. margin: 15px 0;
  821. background-color: #fff;
  822. background-repeat: no-repeat;
  823. border-bottom-right-radius: 12px;
  824. border-bottom-left-radius: 12px;
  825. &::before,
  826. &::after {
  827. position: absolute;
  828. left: 0;
  829. width: 100%;
  830. height: 15px;
  831. pointer-events: none;
  832. content: '';
  833. background: $courseware-bgColor;
  834. }
  835. &::before {
  836. top: -15px;
  837. }
  838. &::after {
  839. bottom: -15px;
  840. }
  841. .row {
  842. display: grid;
  843. gap: $component-spacing;
  844. .col {
  845. display: grid;
  846. gap: $component-spacing;
  847. overflow: hidden;
  848. }
  849. .row-checkbox {
  850. position: absolute;
  851. left: 4px;
  852. }
  853. }
  854. .custom-context-menu,
  855. .remark-info {
  856. position: absolute;
  857. z-index: 999;
  858. display: flex;
  859. gap: 3px;
  860. align-items: center;
  861. font-size: 14px;
  862. cursor: pointer;
  863. }
  864. .custom-context-menu {
  865. padding-left: 30px;
  866. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  867. background-size: 24px;
  868. }
  869. .contentmenu {
  870. position: absolute;
  871. z-index: 999;
  872. display: flex;
  873. column-gap: 4px;
  874. align-items: center;
  875. padding: 8px;
  876. font-size: 14px;
  877. color: #000;
  878. background-color: #e7e7e7;
  879. border-radius: 4px;
  880. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
  881. .svg-icon,
  882. .button {
  883. cursor: pointer;
  884. }
  885. .line {
  886. min-height: 16px;
  887. margin: 0 4px;
  888. }
  889. }
  890. }
  891. </style>