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. zIndex: '9',
  105. }"
  106. ></div>
  107. <!-- 选中文本的工具栏 -->
  108. <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
  109. <template v-if="canRemark">
  110. <!-- <span class="button" @click="handleMenuItemClick($event, 'tool')">
  111. <SvgIcon icon-class="sidebar-pushpin" size="14" /> 添加批注
  112. </span> -->
  113. </template>
  114. <template v-else>
  115. <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
  116. <span class="line"></span>
  117. <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
  118. <span class="line"></span>
  119. <span class="button" @click="setTranslate"> <SvgIcon icon-class="sidebar-translate" size="14" /> 翻译</span>
  120. <!-- <span class="line"></span>
  121. <span class="button" @click="setFeedback"> <SvgIcon icon-class="sidebar-feedback" size="14" /> 用户反馈</span> -->
  122. </template>
  123. </div>
  124. <template v-if="showRemark && Object.keys(componentRemarkObj).length !== 0 && componentRemarkObj['WHOLE']">
  125. <el-popover
  126. v-for="(items, indexs) in componentRemarkObj['WHOLE']"
  127. :key="'menu-remark-info' + indexs"
  128. placement="bottom"
  129. trigger="click"
  130. popper-class="menu-remark-info"
  131. >
  132. <div v-html="items.content"></div>
  133. <template v-if="items.file_list.length > 0">
  134. <div v-for="(item, index) in items.file_list" :key="indexs + '-' + index" class="remark-file-item">
  135. <SvgIcon :icon-class="item.icon_type" />
  136. <span class="file-item-name">{{ item.file_name }}</span>
  137. <SvgIcon icon-class="uploadPreview" @click="viewDialog(item)" />
  138. <SvgIcon icon-class="download" @click="downLoad(item)" />
  139. </div>
  140. </template>
  141. <template #reference>
  142. <div
  143. v-if="items.position_br_x - items.position_x > 3 && items.position_br_y - items.position_y > 3"
  144. slot="reference"
  145. :style="{
  146. position: 'absolute',
  147. top: `${items.position_y}px`,
  148. left: `${items.position_x}px`,
  149. width: `${items.position_br_x - items.position_x}px`,
  150. height: `${items.position_br_y - items.position_y}px`,
  151. border: '2px solid #165DFF',
  152. zIndex: 10,
  153. }"
  154. ></div>
  155. </template>
  156. </el-popover>
  157. </template>
  158. <el-dialog
  159. v-if="visible"
  160. :visible.sync="visible"
  161. :show-close="true"
  162. :close-on-click-modal="true"
  163. :modal-append-to-body="true"
  164. :append-to-body="true"
  165. :lock-scroll="true"
  166. :width="'80%'"
  167. top="0"
  168. >
  169. <iframe v-if="visible" :src="newpath" width="100%" :height="iframeHeight" frameborder="0"></iframe>
  170. </el-dialog>
  171. </div>
  172. </template>
  173. <script>
  174. import { previewComponentList } from '@/views/book/courseware/data/bookType';
  175. import { getToken, getConfig } from '@/utils/auth';
  176. import { buildCoursewareStyle } from '@/views/book/courseware/preview/common/utils/coursewareStyle';
  177. import _ from 'lodash';
  178. const Base64 = require('js-base64').Base64;
  179. export default {
  180. name: 'CoursewarePreview',
  181. provide() {
  182. return {
  183. getDragStatus: () => false,
  184. bookInfo: this.bookInfo,
  185. getCoursewareId: () => this.courseware_id,
  186. };
  187. },
  188. props: {
  189. data: {
  190. type: Object,
  191. default: () => ({}),
  192. },
  193. coursewareId: {
  194. type: String,
  195. default: '',
  196. },
  197. background: {
  198. type: Object,
  199. default: () => ({}),
  200. },
  201. componentList: {
  202. type: Array,
  203. required: true,
  204. },
  205. canRemark: {
  206. type: Boolean,
  207. default: false,
  208. },
  209. showRemark: {
  210. type: Boolean,
  211. default: false,
  212. },
  213. componentRemarkObj: {
  214. type: Object,
  215. default: () => ({}),
  216. },
  217. groupRowList: {
  218. type: Array,
  219. default: () => [],
  220. },
  221. isShowGroup: {
  222. type: Boolean,
  223. default: false,
  224. },
  225. groupShowAll: {
  226. type: Boolean,
  227. default: true,
  228. },
  229. project: {
  230. type: Object,
  231. default: () => ({}),
  232. },
  233. type: {
  234. type: String,
  235. default: '',
  236. },
  237. },
  238. data() {
  239. return {
  240. previewComponentList,
  241. courseware_id: this.coursewareId,
  242. bookInfo: {
  243. theme_color: '',
  244. },
  245. showMenu: false,
  246. divPosition: {
  247. left: 0,
  248. top: 0,
  249. }, // courserware盒子原始距离页面顶部和左边的距离
  250. menuPosition: { x: 0, y: 0, select_node: '', startX: null, startY: null, endX: null, endY: null }, // 用于存储菜单的位置
  251. componentId: '', // 添加批注的组件id
  252. rowCheckList: {},
  253. showToolbar: false,
  254. contentmenu: {
  255. left: 0,
  256. top: 0,
  257. },
  258. selectedInfo: null,
  259. selectHandleInfo: null,
  260. curSelectId: '', // 当前选中组件id
  261. isSelecting: false, // 是否开始框选内容
  262. file_preview_url: getConfig() ? getConfig().doc_preview_service_address : '',
  263. visible: false,
  264. newpath: '',
  265. iframeHeight: `${window.innerHeight - 100}px`,
  266. visible_id: this.$route.query?.visible_id || '', // 可见组件 id
  267. };
  268. },
  269. computed: {
  270. // 计算课件背景样式
  271. computedCoursewareStyle() {
  272. return buildCoursewareStyle(this.background, 'courseware');
  273. },
  274. },
  275. watch: {
  276. groupRowList: {
  277. handler(val) {
  278. if (!val) return;
  279. this.rowCheckList = val
  280. .filter(({ is_pre_same_group }) => !is_pre_same_group)
  281. .reduce((acc, row) => {
  282. acc[row.row_id] = false;
  283. return acc;
  284. }, {});
  285. },
  286. },
  287. coursewareId: {
  288. handler(val) {
  289. this.courseware_id = val;
  290. },
  291. },
  292. },
  293. mounted() {
  294. const element = this.$refs.courseware;
  295. const rect = element.getBoundingClientRect();
  296. this.divPosition = {
  297. left: rect.left,
  298. top: rect.top,
  299. };
  300. window.addEventListener('mousedown', this.handleMouseDown);
  301. this.handleScrollVisibleComponent();
  302. },
  303. beforeDestroy() {
  304. window.removeEventListener('mousedown', this.handleMouseDown);
  305. },
  306. methods: {
  307. /**
  308. * 滚动到 visible_id 的组件
  309. */
  310. async handleScrollVisibleComponent() {
  311. if (!this.visible_id) return;
  312. await this.$nextTick();
  313. const target = await this.findChildComponentByKey(this.visible_id);
  314. if (target && target.$el) {
  315. target.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  316. }
  317. },
  318. /**
  319. * 处理选中组件事件
  320. * 这个事件只在编辑预览模式下触发
  321. * @param {string} component_id 组件在课件内部的 ID
  322. */
  323. selectedComponent(component_id) {
  324. if (this.type !== 'edit_preview') return;
  325. this.curSelectId = component_id;
  326. const selectedComponent = this.componentList.find((item) => item.component_id === component_id);
  327. this.$emit('selectedComponent', { courseware_id: selectedComponent?.courseware_id, component_id });
  328. },
  329. /**
  330. * 计算组件内容
  331. * @param {string} id 组件id
  332. * @returns {string} 组件内容
  333. */
  334. computedColContent(id) {
  335. if (!id) return '';
  336. return this.componentList.find((item) => item.component_id === id)?.content || '';
  337. },
  338. /**
  339. * 计算组件背景
  340. * @param {string} id 组件id
  341. * @returns {object} 组件背景样式
  342. */
  343. computedColBackground(id) {
  344. if (!id) return {};
  345. const background = this.componentList.find((item) => item.component_id === id)?.background;
  346. return background ? JSON.parse(background) : {};
  347. },
  348. getMultipleColStyle(i) {
  349. let row = this.data.row_list[i];
  350. let col = row.col_list;
  351. if (col.length <= 1) {
  352. return {
  353. gridTemplateColumns: '100fr',
  354. };
  355. }
  356. let gridTemplateColumns = row.width_list.join(' ');
  357. return {
  358. gridAutoFlow: 'column',
  359. gridTemplateColumns,
  360. gridTemplateRows: 'auto',
  361. };
  362. },
  363. /**
  364. * 计算行的可见性
  365. * @params {string} rowId 行的ID
  366. * @return {boolean} 行是否可见
  367. */
  368. computedRowVisibility(rowId) {
  369. if (this.groupShowAll) return true;
  370. let { row_id, is_pre_same_group } = this.groupRowList.find(({ row_id }) => row_id === rowId);
  371. if (is_pre_same_group) {
  372. const index = this.groupRowList.findIndex(({ row_id }) => row_id === rowId);
  373. if (index === -1) return false;
  374. for (let i = index - 1; i >= 0; i--) {
  375. if (!this.groupRowList[i].is_pre_same_group) {
  376. return this.rowCheckList[this.groupRowList[i].row_id];
  377. }
  378. }
  379. return false;
  380. }
  381. return this.rowCheckList[row_id];
  382. },
  383. /**
  384. * 计算选中分组行的课件信息
  385. * @returns {object} 选中分组行的课件信息
  386. */
  387. computedSelectedGroupCoursewareInfo() {
  388. if (Object.keys(this.rowCheckList).length === 0) {
  389. return {};
  390. }
  391. // 根据 rowCheckList 过滤出选中的行,获取这些行的组件信息
  392. let coursewareInfo = structuredClone(this.data);
  393. coursewareInfo.row_list = coursewareInfo.row_list.filter((row) => {
  394. let groupRow = this.groupRowList.find(({ row_id }) => row_id === row.row_id);
  395. if (!groupRow.is_pre_same_group) {
  396. return this.rowCheckList[groupRow.row_id];
  397. }
  398. const index = this.groupRowList.findIndex(({ row_id }) => row_id === row.row_id);
  399. if (index === -1) return false;
  400. for (let i = index - 1; i >= 0; i--) {
  401. if (!this.groupRowList[i].is_pre_same_group) {
  402. return this.rowCheckList[this.groupRowList[i].row_id];
  403. }
  404. }
  405. return false;
  406. });
  407. // 获取选中行的所有组件id列表
  408. let component_id_list = coursewareInfo.row_list.flatMap((row) =>
  409. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  410. );
  411. // 获取选中行的分组列表描述
  412. let content_group_row_list = this.groupRowList.filter(({ row_id, is_pre_same_group }) => {
  413. if (!is_pre_same_group) {
  414. return this.rowCheckList[row_id];
  415. }
  416. const index = this.groupRowList.findIndex(({ row_id: id }) => id === row_id);
  417. if (index === -1) return false;
  418. for (let i = index - 1; i >= 0; i--) {
  419. if (!this.groupRowList[i].is_pre_same_group) {
  420. return this.rowCheckList[this.groupRowList[i].row_id];
  421. }
  422. }
  423. return false;
  424. });
  425. let groupIdList = _.cloneDeep(content_group_row_list);
  426. let groupList = [];
  427. // 通过判断 is_pre_same_group 将组合并
  428. for (let i = 0; i < groupIdList.length; i++) {
  429. if (groupIdList[i].is_pre_same_group) {
  430. groupList[groupList.length - 1].row_id_list.push(groupIdList[i].row_id);
  431. } else {
  432. groupList.push({
  433. name: '',
  434. row_id_list: [groupIdList[i].row_id],
  435. component_id_list: [],
  436. });
  437. }
  438. }
  439. // 通过合并后的分组,获取对应的组件 id 和分组名称
  440. groupList.forEach(({ row_id_list, component_id_list }, i) => {
  441. row_id_list.forEach((row_id, j) => {
  442. let row = this.data.row_list.find((row) => {
  443. return row.row_id === row_id;
  444. });
  445. // 当前行所有组件id列表
  446. let gridIdList = row.col_list.map((col) => col.grid_list.map((grid) => grid.id)).flat();
  447. component_id_list.push(...gridIdList);
  448. // 查找每组第一行中第一个包含 describe、label 或 stem 的组件
  449. if (j === 0) {
  450. let findKey = '';
  451. let findType = '';
  452. row.col_list.some((col) => {
  453. const findItem = col.grid_list.find(({ type }) => {
  454. return ['describe', 'label', 'stem'].includes(type);
  455. });
  456. if (findItem) {
  457. findKey = findItem.id;
  458. findType = findItem.type;
  459. return true;
  460. }
  461. });
  462. let groupName = `组${i + 1}`;
  463. // 如果有标签类组件,获取对应名称
  464. if (findKey) {
  465. let item = this.$refs.preview.find(
  466. (child) => child.$el && child.$el.dataset && child.$el.dataset.id === findKey,
  467. );
  468. if (['describe', 'stem'].includes(findType)) {
  469. groupName = item.data.content.replace(/<[^>]+>/g, '');
  470. } else if (findType === 'label') {
  471. groupName = item.data.dynamicTags.map((tag) => tag.text).join(', ');
  472. }
  473. }
  474. groupList[i].name = groupName;
  475. }
  476. });
  477. });
  478. return {
  479. content: JSON.stringify(coursewareInfo),
  480. component_id_list,
  481. content_group_row_list: JSON.stringify(content_group_row_list),
  482. content_group_component_list: JSON.stringify(groupList),
  483. };
  484. },
  485. /**
  486. * 保存课节为样式模板
  487. * @param {object} param0 保存样式模板所需参数
  488. * @param {string} param0.courseware_id 课件id
  489. * @param {'true' | 'false'} param0.is_select_part_courseware_mode 是否选择部分课件模式
  490. * @param {string[]} param0.component_id_list 组件id列表
  491. */
  492. async saveCoursewareStyleTemplate({ courseware_id, is_select_part_courseware_mode, component_id_list = [] }) {
  493. const allComponentIds = this.data.row_list.flatMap((row) =>
  494. row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
  495. );
  496. // 如果是选择部分课件模式,则使用传入的 component_id_list,否则使用所有组件id列表
  497. const idList = is_select_part_courseware_mode === 'true' ? component_id_list : allComponentIds;
  498. const saveTasks = [];
  499. // 遍历组件id列表,找到对应组件并调用其保存样式模板方法
  500. for (const id of idList) {
  501. const component = await this.findChildComponentByKey(id);
  502. if (component && typeof component.saveStyleTemplate === 'function') {
  503. saveTasks.push(component.saveStyleTemplate(courseware_id));
  504. }
  505. }
  506. await Promise.all(saveTasks);
  507. this.$message.success('已保存为样式模板');
  508. },
  509. /**
  510. * 清空行选择列表
  511. */
  512. clearRowCheckList() {
  513. this.rowCheckList = {};
  514. },
  515. /**
  516. * 分割整数为多个 1的倍数
  517. * @param {number} num
  518. * @param {number} parts
  519. */
  520. splitInteger(num, parts) {
  521. let base = Math.floor(num / parts);
  522. let arr = Array(parts).fill(base);
  523. let remainder = num - base * parts;
  524. for (let i = 0; remainder > 0; i = (i + 1) % parts) {
  525. arr[i] += 1;
  526. remainder -= 1;
  527. }
  528. return arr;
  529. },
  530. /**
  531. * 计算列的样式
  532. * @param {Object} col 列对象
  533. * @returns {Object} 列的样式对象
  534. */
  535. computedColStyle(col) {
  536. const grid = col.grid_list || [];
  537. if (grid.length === 0) {
  538. return {
  539. width: col.width,
  540. gridTemplateAreas: '',
  541. gridTemplateColumns: '',
  542. gridTemplateRows: '',
  543. };
  544. }
  545. // 先按 row 分组,避免后续重复 filter 扫描
  546. const rowMap = new Map();
  547. grid.forEach((item) => {
  548. if (!rowMap.has(item.row)) {
  549. rowMap.set(item.row, []);
  550. }
  551. rowMap.get(item.row).push(item);
  552. });
  553. let maxCol = 0;
  554. let curMaxRow = 0;
  555. rowMap.forEach((items, row) => {
  556. if (items.length > maxCol) {
  557. maxCol = items.length;
  558. curMaxRow = row;
  559. }
  560. });
  561. // 计算 grid_template_areas
  562. let gridTemplateAreas = '';
  563. rowMap.forEach((items, row) => {
  564. let rowAreas = [];
  565. if (row === curMaxRow) {
  566. rowAreas = items.map((item) => item.grid_area);
  567. } else {
  568. const needNum = maxCol - items.length;
  569. if (items.length === 1) {
  570. rowAreas = Array(needNum + 1).fill(items[0].grid_area);
  571. } else {
  572. const splitArr = this.splitInteger(needNum, items.length);
  573. rowAreas = items.flatMap((item, index) => Array(splitArr[index] + 1).fill(item.grid_area));
  574. }
  575. }
  576. gridTemplateAreas += `'${rowAreas.join(' ')}' `;
  577. });
  578. // 计算 grid_template_columns
  579. const maxRowItems = rowMap.get(curMaxRow) || [];
  580. const gridTemplateColumns = maxRowItems.length ? `${maxRowItems.map((item) => item.width).join(' ')} ` : '';
  581. // 计算 grid_template_rows
  582. const previewById = new Map();
  583. (this.$refs.preview || []).forEach((child) => {
  584. const id = child?.$el?.dataset?.id;
  585. if (id) {
  586. previewById.set(id, child);
  587. }
  588. });
  589. const hasOperationById = (id) => {
  590. const component = previewById.get(id);
  591. return Boolean(component && component.$el.querySelector('.operation'));
  592. };
  593. const toNumberHeight = (height) => {
  594. const num = Number(String(height).replace('px', ''));
  595. return Number.isFinite(num) ? num : NaN;
  596. };
  597. let gridTemplateRows = '';
  598. rowMap.forEach((items) => {
  599. if (items.length === 1) {
  600. const current = items[0];
  601. if (current.height === 'auto') {
  602. gridTemplateRows += 'auto ';
  603. return;
  604. }
  605. let baseHeight = toNumberHeight(current.height);
  606. if (Number.isNaN(baseHeight)) {
  607. gridTemplateRows += `${current.height} `;
  608. return;
  609. }
  610. if (hasOperationById(current.id)) {
  611. baseHeight += 48;
  612. }
  613. gridTemplateRows += `${baseHeight}px `;
  614. return;
  615. }
  616. const nonAutoItems = items.filter((item) => item.height !== 'auto');
  617. if (nonAutoItems.length === 0) {
  618. gridTemplateRows += 'auto ';
  619. return;
  620. }
  621. let maxItem = null;
  622. let maxHeight = 0;
  623. nonAutoItems.forEach((item) => {
  624. const current = toNumberHeight(item.height);
  625. if (!Number.isNaN(current) && current > maxHeight) {
  626. maxHeight = current;
  627. maxItem = item;
  628. }
  629. });
  630. if (maxItem && hasOperationById(maxItem.id)) {
  631. maxHeight += 48;
  632. }
  633. gridTemplateRows += `${maxHeight}px `;
  634. });
  635. return {
  636. width: col.width,
  637. gridTemplateAreas,
  638. gridTemplateColumns,
  639. gridTemplateRows,
  640. };
  641. },
  642. handleContextMenu(event, id) {
  643. if (this.canRemark) {
  644. event.preventDefault(); // 阻止默认的上下文菜单显示
  645. this.menuPosition = {
  646. x: event.clientX - this.divPosition.left,
  647. y: event.clientY - this.divPosition.top,
  648. }; // 设置菜单位置
  649. this.componentId = id;
  650. this.$emit('computeScroll');
  651. }
  652. },
  653. handleResult(top, left, select_node) {
  654. this.menuPosition.x += left;
  655. this.menuPosition.y += top;
  656. this.menuPosition.select_node = select_node;
  657. // 设置菜单位置
  658. this.showMenu = true; // 显示菜单
  659. },
  660. handleMenuItemClick(event, type) {
  661. this.showMenu = false; // 隐藏菜单
  662. let text = '';
  663. if (type && type === 'tool') {
  664. let info = this.selectHandleInfo;
  665. this.menuPosition = {
  666. x: event.clientX - this.divPosition.left,
  667. y: event.clientY - this.divPosition.top - 20,
  668. }; // 设置菜单位置
  669. this.componentId = info.blockId;
  670. text = info.text;
  671. this.showMenu = false;
  672. } else {
  673. this.componentId = this.getElementFromPoint(
  674. (this.menuPosition.startX + this.menuPosition.endX) / 2,
  675. (this.menuPosition.startY + this.menuPosition.endY) / 2,
  676. );
  677. }
  678. this.$emit('computeScroll');
  679. setTimeout(() => {
  680. this.$emit(
  681. 'addRemark',
  682. this.menuPosition.select_node,
  683. this.menuPosition.startX,
  684. this.menuPosition.startY,
  685. this.menuPosition.endX,
  686. this.menuPosition.endY,
  687. this.componentId,
  688. text,
  689. );
  690. }, 10);
  691. },
  692. handleMouseDown(event) {
  693. if (event.button === 0 && event.target.className !== 'custom-context-menu') {
  694. // 0 表示左键
  695. this.showMenu = false;
  696. }
  697. },
  698. /**
  699. * 查找子组件
  700. * @param {string} id 组件的唯一标识符
  701. * @returns {Promise<HTMLElement|null>} 返回找到的子组件或 null
  702. */
  703. async findChildComponentByKey(id) {
  704. await this.$nextTick();
  705. if (!this.$refs.preview) {
  706. // 最多等待 1000ms
  707. for (let i = 0; i < 20; i++) {
  708. await this.$nextTick();
  709. await new Promise((resolve) => setTimeout(resolve, 50));
  710. if (this.$refs.preview) break;
  711. }
  712. }
  713. // 如果等待后还是不存在,那就返回null
  714. if (!this.$refs.preview) {
  715. console.error('$refs.preview 不存在');
  716. return null;
  717. }
  718. return this.$refs.preview.find((child) => child.$el && child.$el.dataset && child.$el.dataset.id === id);
  719. },
  720. /**
  721. * 模拟回答
  722. * @param {boolean} isJudgingRightWrong 是否判断对错
  723. * @param {boolean} isShowRightAnswer 是否显示正确答案
  724. * @param {boolean} disabled 是否禁用
  725. */
  726. simulateAnswer(isJudgingRightWrong, isShowRightAnswer, disabled = true) {
  727. this.$refs.preview.forEach((item) => {
  728. item.showAnswer(isJudgingRightWrong, isShowRightAnswer, null, disabled);
  729. });
  730. },
  731. /**
  732. * 处理组件高度变化事件
  733. * @param {string} id 组件id
  734. * @param {string} newHeight 组件的新高度
  735. */
  736. handleHeightChange(id, newHeight) {
  737. this.data.row_list.forEach((row) => {
  738. row.col_list.forEach((col) => {
  739. col.grid_list.forEach((grid) => {
  740. if (grid.id === id) {
  741. grid.height = newHeight;
  742. }
  743. });
  744. });
  745. });
  746. },
  747. // 处理选中文本
  748. handleTextSelection() {
  749. this.showToolbar = false;
  750. setTimeout(() => {
  751. const selection = window.getSelection();
  752. if (selection.toString().trim() === '') return null;
  753. const selectedText = selection.toString().trim();
  754. const range = selection.getRangeAt(0);
  755. this.selectedInfo = {
  756. text: selectedText,
  757. range,
  758. };
  759. let selectHandleInfo = this.getSelectionInfo();
  760. if (!selectHandleInfo || !selectHandleInfo.text) return;
  761. this.selectHandleInfo = selectHandleInfo;
  762. if (!this.canRemark) this.showToolbar = true;
  763. const selectRect = range.getBoundingClientRect();
  764. this.contentmenu = {
  765. position: 'fixed',
  766. left: `${Math.round(selectRect.left + selectRect.width / 2 - 63)}px`,
  767. top: `${Math.round(selectRect.bottom + 5)}px`,
  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. max-width: $courseware-width;
  1092. height: 100%;
  1093. min-height: calc(100vh - 226px);
  1094. padding-top: $courseware-top-padding;
  1095. padding-bottom: $courseware-bottom-padding;
  1096. margin: 15px 0;
  1097. background-repeat: no-repeat;
  1098. border-bottom-right-radius: 12px;
  1099. border-bottom-left-radius: 12px;
  1100. &::before {
  1101. top: -15px;
  1102. }
  1103. &::after {
  1104. bottom: -15px;
  1105. }
  1106. .row {
  1107. display: grid;
  1108. gap: $component-spacing;
  1109. .col {
  1110. display: grid;
  1111. gap: $component-spacing;
  1112. .active {
  1113. box-shadow: 0 0 6px 1px $main-hover-color;
  1114. }
  1115. }
  1116. .row-checkbox {
  1117. position: absolute;
  1118. left: -20px;
  1119. }
  1120. }
  1121. .custom-context-menu,
  1122. .remark-info {
  1123. position: absolute;
  1124. z-index: 999;
  1125. display: flex;
  1126. gap: 3px;
  1127. align-items: center;
  1128. font-size: 14px;
  1129. cursor: pointer;
  1130. }
  1131. .custom-context-menu {
  1132. padding-left: 30px;
  1133. background: url('../../../../assets/icon-publish.png') left center no-repeat;
  1134. background-size: 24px;
  1135. }
  1136. .contentmenu {
  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>