CoursewarePreview.vue 43 KB

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