index.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260
  1. <template>
  2. <div class="pptist-student-viewer" :class="{ 'fullscreen': isFullscreen, 'laser-pen': laserPen }">
  3. <!-- 左侧导航栏 -->
  4. <div class="layout-content-left" v-show="type == '1'">
  5. <div class="thumbnails">
  6. <div class="viewer-header">
  7. <h3>幻灯片导航</h3>
  8. </div>
  9. <div class="thumbnail-list">
  10. <div v-for="(slide, index) in slides" :key="slide.id" class="thumbnail-item"
  11. :class="{ 'active': slideIndex === index }" @click="goToSlide(index)">
  12. <div class="label">{{ fillDigit(index + 1, 2) }}</div>
  13. <ThumbnailSlide class="thumbnail" :slide="slide" :size="168" :visible="true" @click="goToSlide(index)" />
  14. </div>
  15. </div>
  16. <!-- <div class="page-number">幻灯片 {{ slideIndex + 1 }} / {{ slides.length }}</div>
  17. <div class="progress-bar">
  18. <div class="progress-fill" :style="{ width: `${((slideIndex + 1) / slides.length) * 100}%` }"></div>
  19. </div> -->
  20. </div>
  21. </div>
  22. <!-- 中间放映区域 -->
  23. <div class="layout-content-center">
  24. <div class="viewer-header" :class="{ 'hidden': isFullscreen }">
  25. <div class="slide-title">幻灯片 {{ slideIndex + 1 }}</div>
  26. <div class="viewer-controls">
  27. <button @click="previousSlide" :disabled="slideIndex === 0" title="上一页">
  28. <IconLeftTwo class="control-icon" />
  29. </button>
  30. <button @click="nextSlide" :disabled="slideIndex === slides.length - 1" title="下一页">
  31. <IconRightTwo class="control-icon" />
  32. </button>
  33. <!-- <button @click="resetZoom" title="重置缩放">
  34. <IconUndo class="control-icon" />
  35. </button> -->
  36. <button @click="enterFullscreen" title="全屏">
  37. <IconFullScreenOne class="control-icon" />
  38. </button>
  39. <!-- <button @click="backToEditor" class="back-btn" title="返回编辑">
  40. <IconEdit class="control-icon" />
  41. </button> -->
  42. </div>
  43. </div>
  44. <div class="viewer-canvas" ref="viewerCanvasRef">
  45. <!-- 全屏时:使用放映功能 -->
  46. <!-- <ScreenSlideList :slideWidth="slideWidth"
  47. :slideHeight="slideHeight" :animationIndex="0" :turnSlideToId="() => { }"
  48. :manualExitFullscreen="() => { }" /> -->
  49. <!-- 不全屏时:使用编辑模式的显示比例和居中逻辑 -->
  50. <div class="slide-list-wrap" :style="{
  51. width: isFullscreen ? '100%' : (slideWidth * canvasScale) + 'px',
  52. height: isFullscreen ? '100%' : (slideHeight * canvasScale) + 'px',
  53. left: isFullscreen ? '0' : `${(containerWidth - slideWidth * canvasScale) / 2}px`,
  54. top: isFullscreen ? '0' : `${(containerHeight - slideHeight * canvasScale) / 2}px`
  55. }">
  56. <div class="viewport" v-if="false">
  57. <div class="background" :style="backgroundStyle"></div>
  58. <ScreenElement v-for="(element, index) in elementList" :key="element.id" :elementInfo="element"
  59. :elementIndex="index + 1" :animationIndex="0" :turnSlideToId="() => { }"
  60. :manualExitFullscreen="() => { }" />
  61. </div>
  62. <ScreenSlideList :slideWidth="slideWidth * canvasScale" :slideHeight="slideHeight * canvasScale"
  63. :animationIndex="0" :turnSlideToId="() => { }" :manualExitFullscreen="() => { }" />
  64. </div>
  65. <!-- 全屏时的左右下角工具按钮 -->
  66. <div v-if="isFullscreen" class="tools-left">
  67. <IconLeftTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="previousSlide" />
  68. <IconRightTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="nextSlide" />
  69. </div>
  70. <!-- 作业提交按钮 - 当当前幻灯片包含iframe时显示 -->
  71. <div v-if="currentSlideHasIframe" class="homework-submit-btn" :class="{ 'submitting': isSubmitting }"
  72. :style="{ right: getHomeworkButtonRight() + 'px' }" @click="handleHomeworkSubmit"
  73. v-tooltip="isSubmitting ? '作业提交中...' : '作业提交'">
  74. <IconEdit v-if="!isSubmitting" class="tool-btn" />
  75. <div v-else class="loading-spinner"></div>
  76. <span class="btn-text">{{ isSubmitting ? '提交中...' : '作业提交' }}</span>
  77. </div>
  78. <!-- 功能组件 -->
  79. <SlideThumbnails v-if="slideThumbnailModelVisible" :turnSlideToIndex="goToSlide"
  80. @close="slideThumbnailModelVisible = false" />
  81. <WritingBoardTool :slideWidth="slideWidth" :slideHeight="slideHeight" v-if="writingBoardToolVisible"
  82. @close="writingBoardToolVisible = false" />
  83. <CountdownTimer v-if="timerlVisible" @close="timerlVisible = false" />
  84. <div v-if="isFullscreen" class="tools-right" :class="{ 'visible': rightToolsVisible }"
  85. @mouseleave="rightToolsVisible = false" @mouseenter="rightToolsVisible = true">
  86. <div class="content">
  87. <div class="tool-btn page-number" @click="slideThumbnailModelVisible = true">幻灯片 {{ slideIndex +
  88. 1 }} / {{ slides.length }}</div>
  89. <IconWrite class="tool-btn" v-tooltip="'画笔工具'" @click="writingBoardToolVisible = true" />
  90. <IconMagic class="tool-btn" v-tooltip="'激光笔'" :class="{ 'active': laserPen }" @click="toggleLaserPen" />
  91. <IconStopwatchStart class="tool-btn" v-tooltip="'计时器'" @click="timerlVisible = !timerlVisible" />
  92. <IconOffScreenOne class="tool-btn" v-tooltip="'退出全屏'" @click="enterFullscreen" />
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <div class="layout-content-right" v-show="type == '1'">
  98. <div class="thumbnails">
  99. <div class="viewer-header">
  100. <h3>作业区</h3>
  101. </div>
  102. </div>
  103. </div>
  104. </div>
  105. </template>
  106. <script lang="ts" setup>
  107. import { computed, ref, onMounted, onUnmounted, nextTick, inject, watch } from 'vue'
  108. import { storeToRefs } from 'pinia'
  109. import { useSlidesStore } from '@/store'
  110. import { ElementTypes } from '@/types/slides'
  111. import { fillDigit } from '@/utils/common'
  112. import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
  113. import ScreenSlideList from '@/views/Screen/ScreenSlideList.vue'
  114. import ScreenElement from '@/views/Screen/ScreenElement.vue'
  115. import SlideThumbnails from '@/views/Screen/SlideThumbnails.vue'
  116. import WritingBoardTool from '@/views/Screen/WritingBoardTool.vue'
  117. import CountdownTimer from '@/views/Screen/CountdownTimer.vue'
  118. import useSlideBackgroundStyle from '@/hooks/useSlideBackgroundStyle'
  119. import useImport from '@/hooks/useImport'
  120. import message from '@/utils/message'
  121. import api from '@/services/course'
  122. // 定义组件props
  123. interface Props {
  124. courseid?: string | null
  125. userid?: string | null
  126. oid?: string | null
  127. org?: string | null
  128. cid?: string | null
  129. type?: string | null
  130. }
  131. const props = withDefaults(defineProps<Props>(), {
  132. courseid: null,
  133. userid: null,
  134. oid: null,
  135. org: null,
  136. cid: null,
  137. type: null,
  138. })
  139. // 图标组件通过全局注册,无需导入
  140. const slidesStore = useSlidesStore()
  141. const { slides, slideIndex, currentSlide, viewportSize, viewportRatio } = storeToRefs(slidesStore)
  142. // 添加容器引用,用于计算幻灯片尺寸
  143. const viewerCanvasRef = ref<HTMLElement>()
  144. // 放映相关的状态
  145. const canvasScale = ref(1) // 画布缩放比例
  146. const isFullscreen = ref(false) // 是否全屏
  147. const containerWidth = ref(0) // 容器宽度
  148. const containerHeight = ref(0) // 容器高度
  149. // 全屏工具相关状态
  150. const rightToolsVisible = ref(false)
  151. const writingBoardToolVisible = ref(false)
  152. const timerlVisible = ref(false)
  153. const slideThumbnailModelVisible = ref(false)
  154. const laserPen = ref(false)
  155. // 作业提交状态
  156. const isSubmitting = ref(false)
  157. // 控制组件显示的开关
  158. const showSlideList = ref(true)
  159. const slideWidth = ref(0)
  160. const slideHeight = ref(0)
  161. // 计算幻灯片尺寸的函数
  162. const calculateSlideSize = () => {
  163. const slideWrapRef = isFullscreen.value ? document.body : viewerCanvasRef.value
  164. const winWidth = slideWrapRef.clientWidth
  165. const winHeight = slideWrapRef.clientHeight
  166. // 根据视口比例计算最佳尺寸
  167. if (winHeight / winWidth === viewportRatio.value) {
  168. slideWidth.value = winWidth
  169. slideHeight.value = winHeight
  170. }
  171. else if (winHeight / winWidth > viewportRatio.value) {
  172. slideWidth.value = winWidth
  173. slideHeight.value = winWidth * viewportRatio.value
  174. }
  175. else {
  176. slideWidth.value = winHeight / viewportRatio.value
  177. slideHeight.value = winHeight
  178. }
  179. }
  180. // 使用编辑模式的缩放逻辑
  181. const calculateScale = () => {
  182. console.log('calculateScale 开始执行')
  183. // 计算幻灯片尺寸
  184. calculateSlideSize()
  185. // 获取容器尺寸
  186. const container = viewerCanvasRef.value || document.querySelector('.viewer-canvas')
  187. if (container) {
  188. containerWidth.value = container.clientWidth
  189. containerHeight.value = container.clientHeight
  190. console.log('容器尺寸:', {
  191. width: containerWidth.value,
  192. height: containerHeight.value
  193. })
  194. // 计算基础尺寸
  195. const baseWidth = viewportSize.value
  196. const baseHeight = viewportSize.value * viewportRatio.value
  197. console.log('基础尺寸:', {
  198. baseWidth,
  199. baseHeight,
  200. viewportSize: viewportSize.value,
  201. viewportRatio: viewportRatio.value
  202. })
  203. // 计算缩放比例,让幻灯片能够合理利用空间
  204. const scaleX = containerWidth.value / baseWidth
  205. const scaleY = containerHeight.value / baseHeight
  206. console.log('原始缩放比例:', { scaleX, scaleY })
  207. // 选择较小的缩放比例,确保幻灯片完全显示且居中,留10%边距
  208. const scale = Math.min(scaleX, scaleY) * 0.9
  209. console.log('最终缩放比例:', scale)
  210. canvasScale.value = isFullscreen.value ? 1 : props.type == '1' ? 0.9 : 0.95
  211. }
  212. else {
  213. console.error('找不到容器元素')
  214. }
  215. }
  216. // 简化:直接使用放映功能的缩放逻辑
  217. const resetZoom = () => {
  218. calculateScale()
  219. }
  220. // 背景样式
  221. const background = computed(() => currentSlide.value?.background)
  222. const { backgroundStyle } = useSlideBackgroundStyle(background)
  223. // 计算当前幻灯片的元素列表
  224. const elementList = computed(() => {
  225. return currentSlide.value?.elements || []
  226. })
  227. // 检测当前幻灯片是否包含iframe元素
  228. const currentSlideHasIframe = computed(() => {
  229. return elementList.value.some(element => element.type === ElementTypes.FRAME)
  230. })
  231. // 跳转到指定幻灯片
  232. const goToSlide = (index: number) => {
  233. console.log('goToSlide 被调用,目标索引:', index)
  234. console.log('当前索引:', slideIndex.value)
  235. slidesStore.updateSlideIndex(index)
  236. console.log('更新后的索引:', slideIndex.value)
  237. }
  238. // 上一页
  239. const previousSlide = () => {
  240. if (slideIndex.value > 0) {
  241. slidesStore.updateSlideIndex(slideIndex.value - 1)
  242. }
  243. }
  244. // 下一页
  245. const nextSlide = () => {
  246. if (slideIndex.value < slides.value.length - 1) {
  247. slidesStore.updateSlideIndex(slideIndex.value + 1)
  248. }
  249. }
  250. // 全屏
  251. const enterFullscreen = () => {
  252. if (document.fullscreenElement) {
  253. document.exitFullscreen()
  254. }
  255. else {
  256. document.documentElement.requestFullscreen()
  257. }
  258. }
  259. // 监听全屏状态变化
  260. const handleFullscreenChange = () => {
  261. isFullscreen.value = !!document.fullscreenElement
  262. if (isFullscreen.value) {
  263. // 全屏时不需要计算缩放,直接使用放映功能
  264. console.log('进入全屏模式')
  265. }
  266. else {
  267. // 退出全屏时重置所有工具状态并重新计算缩放比例
  268. console.log('退出全屏模式,重置工具状态')
  269. // 重置所有工具状态
  270. rightToolsVisible.value = false
  271. writingBoardToolVisible.value = false
  272. timerlVisible.value = false
  273. slideThumbnailModelVisible.value = false
  274. laserPen.value = false
  275. // 重新计算缩放比例
  276. nextTick(() => {
  277. calculateScale()
  278. })
  279. }
  280. }
  281. // 切换激光笔模式
  282. const toggleLaserPen = () => {
  283. laserPen.value = !laserPen.value
  284. console.log('激光笔状态:', laserPen.value ? '开启' : '关闭')
  285. }
  286. // 获取导入导出功能
  287. const { readJSON, exportJSON2 } = useImport()
  288. // 根据iframe的URL查找对应的幻灯片索引
  289. const findSlideIndexByIframeUrl = (iframeUrl: string): number => {
  290. try {
  291. console.log('查找iframe对应的幻灯片索引,iframe URL:', iframeUrl)
  292. // 遍历所有幻灯片,查找包含该iframe URL的幻灯片
  293. for (let i = 0; i < slides.value.length; i++) {
  294. const slide = slides.value[i]
  295. // 检查幻灯片的元素中是否有iframe
  296. if (slide.elements && slide.elements.length > 0) {
  297. for (const element of slide.elements) {
  298. // 检查是否是iframe元素
  299. if (element.type === ElementTypes.FRAME) {
  300. // 检查iframe的src是否匹配
  301. if (element.url === iframeUrl) {
  302. console.log(`找到匹配的幻灯片,索引: ${i}, 幻灯片ID: ${slide.id}`)
  303. return i
  304. }
  305. }
  306. }
  307. }
  308. }
  309. // 如果没有找到匹配的幻灯片,返回当前幻灯片索引
  310. console.log('未找到匹配的幻灯片,使用当前幻灯片索引:', slideIndex.value)
  311. return slideIndex.value
  312. }
  313. catch (error) {
  314. console.error('查找幻灯片索引时出错:', error)
  315. return slideIndex.value
  316. }
  317. }
  318. // 处理iframe链接,为包含workPage的iframe添加必要参数
  319. const processIframeLinks = () => {
  320. try {
  321. console.log('开始处理iframe链接')
  322. console.log('当前props:', { courseid: props.courseid, userid: props.userid })
  323. // 从slides数据中查找包含iframe的元素
  324. let hasIframe = false
  325. const updatedSlides = slides.value.map((slide, slideIndex) => {
  326. if (slide.elements && slide.elements.length > 0) {
  327. const updatedElements = slide.elements.map(element => {
  328. // 检查是否是iframe元素
  329. if (element.type === ElementTypes.FRAME && element.url) {
  330. const iframeSrc = element.url
  331. if (iframeSrc.includes('workPage')) {
  332. hasIframe = true
  333. console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
  334. try {
  335. // 解析URL,处理hash部分
  336. let baseUrl = iframeSrc
  337. let hashPart = ''
  338. // 分离base URL和hash部分
  339. if (iframeSrc.includes('#')) {
  340. const parts = iframeSrc.split('#')
  341. baseUrl = parts[0]
  342. hashPart = parts[1]
  343. }
  344. // 构建新的hash部分,添加参数
  345. // 使用当前幻灯片索引作为task参数
  346. let newHash = hashPart
  347. if (newHash.includes('?')) {
  348. // 如果hash中已经有查询参数,添加&
  349. newHash += `&courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
  350. }
  351. else {
  352. // 如果hash中没有查询参数,添加?
  353. newHash += `?courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
  354. }
  355. // 构建新的URL
  356. const newUrl = `${baseUrl}#${newHash}`
  357. console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
  358. // 返回更新后的元素
  359. return {
  360. ...element,
  361. url: newUrl
  362. }
  363. }
  364. catch (error) {
  365. console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
  366. return element
  367. }
  368. }
  369. }
  370. // 不是iframe元素或不需要处理,直接返回
  371. return element
  372. })
  373. // 返回更新后的幻灯片
  374. return {
  375. ...slide,
  376. elements: updatedElements
  377. }
  378. }
  379. // 没有元素的幻灯片直接返回
  380. return slide
  381. })
  382. if (hasIframe) {
  383. console.log('找到iframe元素,更新slides数据')
  384. // 更新store中的slides数据
  385. slidesStore.setSlides(updatedSlides)
  386. console.log('slides数据更新完成')
  387. }
  388. else {
  389. console.log('未找到包含workPage的iframe元素')
  390. }
  391. console.log('iframe链接处理完成')
  392. }
  393. catch (error) {
  394. console.error('处理iframe链接时出错:', error)
  395. }
  396. }
  397. // 导入JSON功能
  398. const importJSON = (jsonData: any) => {
  399. try {
  400. console.log('Student importJSON 开始执行')
  401. const result = readJSON(jsonData, true)
  402. if (result.success) {
  403. console.log('Student importJSON 成功,开始重新渲染')
  404. // 强制重新渲染:先隐藏组件
  405. showSlideList.value = false
  406. // 重新计算画布尺寸和缩放比例
  407. nextTick(() => {
  408. calculateScale()
  409. // 延迟500ms后重新显示组件,确保重新渲染完成
  410. setTimeout(() => {
  411. showSlideList.value = true
  412. console.log('组件重新渲染完成')
  413. }, 500)
  414. })
  415. return true
  416. }
  417. console.error('Student importJSON 失败:', result.error)
  418. return false
  419. }
  420. catch (error) {
  421. console.error('Student importJSON 执行失败:', error)
  422. return false
  423. }
  424. }
  425. // 导出JSON功能
  426. const exportJSON = () => {
  427. try {
  428. console.log('Student exportJSON 开始执行,调用 useImport.exportJSON2')
  429. // 直接调用 useImport 中的 exportJSON2 函数
  430. const exportData = exportJSON2()
  431. if (exportData) {
  432. return exportData
  433. }
  434. console.error('Student exportJSON 失败: exportJSON2 返回空数据')
  435. return false
  436. }
  437. catch (error) {
  438. console.error('Student exportJSON 执行失败:', error)
  439. return false
  440. }
  441. }
  442. // 返回编辑器
  443. const backToEditor = () => {
  444. // 通过路由跳转到编辑模式
  445. window.location.href = '/'
  446. }
  447. const submitWork = async (slideIndex: number, atool: string, content: string, type: string) => {
  448. const res = await api.submitWork({
  449. uid: props.userid as string,
  450. cid: props.courseid as string,
  451. stage: '0',
  452. task: String(slideIndex), // 转为字符串
  453. tool: '0',
  454. atool: atool,
  455. content: content,
  456. type: type
  457. })
  458. console.log(res)
  459. }
  460. // 作业提交功能(优化版)
  461. const handleHomeworkSubmit = async () => {
  462. console.log('作业提交按钮被点击')
  463. // 防抖:如果正在提交中,直接返回
  464. if (isSubmitting.value) {
  465. console.log('作业正在提交中,忽略重复点击')
  466. return
  467. }
  468. isSubmitting.value = true
  469. try {
  470. // 获取所有iframe元素
  471. const iframes = document.querySelectorAll('.viewer-canvas iframe')
  472. console.log('找到iframe元素数量:', iframes.length)
  473. if (iframes.length === 0) {
  474. message.warning('当前页面没有找到iframe元素')
  475. return
  476. }
  477. let hasSubmitWork = false
  478. for (let i = 0; i < iframes.length; i++) {
  479. const iframe = iframes[i] as HTMLIFrameElement
  480. const iframeSrc = iframe.src
  481. console.log(`iframe ${i + 1} 链接:`, iframeSrc)
  482. // 检查iframe链接是否包含workPage
  483. if (iframeSrc && iframeSrc.includes('workPage')) {
  484. console.log('找到包含workPage的iframe,尝试执行submitWork')
  485. try {
  486. const iframeWindow = iframe.contentWindow as Window & { submitWork?: (...args: any[]) => unknown }
  487. if (iframeWindow && typeof iframeWindow.submitWork === 'function') {
  488. console.log('执行iframe中的submitWork方法,参数可变')
  489. const iframeSlideIndex = slideIndex.value
  490. const submitArgs = [iframeSlideIndex]
  491. // 支持同步和异步submitWork
  492. const result = iframeWindow.submitWork(...submitArgs)
  493. if (result instanceof Promise) {
  494. await result
  495. console.log('submitWork异步执行完成')
  496. }
  497. else {
  498. console.log('submitWork同步执行完成')
  499. }
  500. message.success('作业提交成功')
  501. hasSubmitWork = true
  502. break
  503. }
  504. else {
  505. console.log('iframe中没有找到submitWork方法')
  506. }
  507. }
  508. catch (error) {
  509. console.error('访问iframe内容时出错:', error)
  510. }
  511. }
  512. else if (iframeSrc && (iframeSrc.includes('aichat.cocorobo') || iframeSrc.includes('knowledge.cocorobo'))) {
  513. console.log('找到包含aichat.cocorobo或knowledge.cocorobo的iframe,尝试执行submitWork')
  514. // 由于TS类型检查,需通过 any 绕过类型限制
  515. const iframeWindow = iframe.contentWindow as any
  516. if (iframeWindow && iframeWindow.exposed_outputs) {
  517. console.log('执行iframe中的submitWork方法,参数可变')
  518. const iframeSlideIndex = slideIndex.value
  519. const Cow = JSON.stringify(iframeWindow.exposed_outputs)
  520. // 这里假设 submitWork 是全局可用的函数
  521. await submitWork(iframeSlideIndex, '72', Cow, '20')
  522. message.success('作业提交成功')
  523. hasSubmitWork = true
  524. }
  525. }
  526. else {
  527. console.log('尝试截图当前页面并提交')
  528. try {
  529. // 导入html-to-image库
  530. const { toPng } = await import('html-to-image')
  531. // 截图当前页面
  532. const imageData = await toPng(document.querySelector('.viewer-canvas') as HTMLElement, {
  533. quality: 0.95,
  534. backgroundColor: '#ffffff'
  535. })
  536. // 提交截图
  537. await submitWork(slideIndex.value, '73', imageData, '1') // 73表示截图工具,21表示图片类型
  538. message.success('页面截图提交成功')
  539. hasSubmitWork = true
  540. }
  541. catch (error) {
  542. console.error('截图提交失败:', error)
  543. message.error('截图提交失败')
  544. }
  545. }
  546. }
  547. if (!hasSubmitWork) {
  548. message.info('未找到可用的作业提交功能')
  549. }
  550. }
  551. catch (error) {
  552. console.error('作业提交过程中出错:', error)
  553. message.error('作业提交失败')
  554. }
  555. finally {
  556. isSubmitting.value = false
  557. }
  558. }
  559. // 获取作业提交按钮的右侧位置
  560. const getHomeworkButtonRight = () => {
  561. if (isFullscreen.value) {
  562. return 30 // 全屏时按钮在右侧30px
  563. }
  564. if (props.type === '1') {
  565. return 230 // type=1时(有左侧导航栏)按钮在右侧230px
  566. }
  567. return 30 // type=2时按钮在右侧30px
  568. }
  569. // 键盘快捷键
  570. const handleKeydown = (e: KeyboardEvent) => {
  571. switch (e.key) {
  572. case 'ArrowLeft':
  573. case 'PageUp':
  574. e.preventDefault()
  575. previousSlide()
  576. break
  577. case 'ArrowRight':
  578. case 'PageDown':
  579. case ' ':
  580. e.preventDefault()
  581. nextSlide()
  582. break
  583. case 'F11':
  584. e.preventDefault()
  585. enterFullscreen()
  586. break
  587. case 'Escape':
  588. if (document.fullscreenElement) {
  589. document.exitFullscreen()
  590. }
  591. break
  592. default:
  593. break
  594. }
  595. }
  596. // 事件处理函数
  597. const handleSlidesDataUpdated = () => {
  598. console.log('收到 slidesDataUpdated 事件')
  599. // 强制重新渲染:先隐藏组件
  600. showSlideList.value = false
  601. nextTick(() => {
  602. calculateScale()
  603. // 延迟500ms后重新显示组件,确保重新渲染完成
  604. setTimeout(() => {
  605. showSlideList.value = true
  606. console.log('组件重新渲染完成')
  607. // 重新处理iframe链接
  608. processIframeLinks()
  609. }, 500)
  610. console.log('slidesDataUpdated 事件处理完成')
  611. })
  612. }
  613. const handleViewportSizeUpdated = (event: any) => {
  614. console.log('收到 viewportSizeUpdated 事件:', event.detail)
  615. // 重新计算缩放比例
  616. nextTick(() => {
  617. calculateScale()
  618. console.log('viewportSizeUpdated 事件处理完成')
  619. })
  620. }
  621. const getCourseDetail = async () => {
  622. const res = await api.getCourseDetail(props.courseid as string)
  623. console.log(res)
  624. const courseDetail = res[0][0]
  625. const pptdata = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : []
  626. importJSON(pptdata)
  627. }
  628. onMounted(() => {
  629. document.addEventListener('keydown', handleKeydown)
  630. // 处理URL参数
  631. if (props.courseid || props.type) {
  632. console.log('收到URL参数:', { courseid: props.courseid, type: props.type })
  633. // 这里可以根据courseid和type进行相应的处理
  634. // 比如加载特定的课程数据、设置特定的显示模式等
  635. if (props.courseid) {
  636. console.log('课程ID:', props.courseid)
  637. // TODO: 根据courseid加载对应的课程数据
  638. }
  639. if (props.type) {
  640. console.log('类型:', props.type)
  641. // TODO: 根据type设置特定的显示模式或功能
  642. }
  643. }
  644. getCourseDetail()
  645. // 计算初始缩放比例
  646. nextTick(() => {
  647. calculateScale()
  648. // 处理iframe链接
  649. processIframeLinks()
  650. })
  651. // 监听窗口大小变化
  652. window.addEventListener('resize', calculateScale)
  653. // 监听全屏状态变化
  654. document.addEventListener('fullscreenchange', handleFullscreenChange)
  655. // 监听幻灯片数据更新事件(来自useImport的readJSON)
  656. window.addEventListener('slidesDataUpdated', handleSlidesDataUpdated)
  657. // 监听视口尺寸更新事件
  658. window.addEventListener('viewportSizeUpdated', handleViewportSizeUpdated)
  659. // 将导入导出功能暴露到window上,方便调试和外部调用
  660. ; (window as any).PPTistStudent = {
  661. importJSON,
  662. exportJSON,
  663. slides: slidesStore.slides,
  664. currentSlide: computed(() => slidesStore.currentSlide),
  665. slideIndex: computed(() => slidesStore.slideIndex),
  666. goToSlide,
  667. previousSlide,
  668. nextSlide,
  669. enterFullscreen,
  670. toggleLaserPen,
  671. // 添加URL参数到全局对象中
  672. courseid: props.courseid,
  673. type: props.type
  674. }
  675. console.log('PPTist Student View 已加载,可通过 window.PPTistStudent 访问功能')
  676. console.log('URL参数:', { courseid: props.courseid, type: props.type })
  677. })
  678. onUnmounted(() => {
  679. document.removeEventListener('keydown', handleKeydown)
  680. window.removeEventListener('resize', calculateScale)
  681. document.removeEventListener('fullscreenchange', handleFullscreenChange)
  682. // 移除幻灯片数据更新事件监听器
  683. window.removeEventListener('slidesDataUpdated', handleSlidesDataUpdated)
  684. // 移除视口尺寸更新事件监听器
  685. window.removeEventListener('viewportSizeUpdated', handleViewportSizeUpdated)
  686. // 清理window上的引用
  687. if ((window as any).PPTistStudent) {
  688. delete (window as any).PPTistStudent
  689. console.log('PPTist Student View 已卸载,window.PPTistStudent 已清理')
  690. }
  691. })
  692. </script>
  693. <style lang="scss" scoped>
  694. .pptist-student-viewer {
  695. height: 100vh;
  696. display: flex;
  697. background-color: #f5f5f5;
  698. // 全屏模式样式
  699. &.fullscreen {
  700. .layout-content-left {
  701. display: none; // 全屏时隐藏左侧导航栏
  702. }
  703. .layout-content-right {
  704. display: none; // 全屏时隐藏左侧导航栏
  705. }
  706. .viewer-header {
  707. display: none; // 全屏时隐藏顶部标题栏
  708. }
  709. }
  710. // 激光笔模式样式
  711. &.laser-pen {
  712. cursor: url(data:image/png;base64,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) 20 20, default !important;
  713. }
  714. }
  715. .layout-content-left {
  716. width: 200px;
  717. height: 100%;
  718. background-color: #fff;
  719. border-right: 1px solid #e0e0e0;
  720. overflow-y: auto;
  721. }
  722. .layout-content-right {
  723. width: 200px;
  724. height: 100%;
  725. background-color: #fff;
  726. border-left: 1px solid #e0e0e0;
  727. overflow-y: auto;
  728. }
  729. .thumbnails {
  730. padding: 0;
  731. .viewer-header {
  732. margin-bottom: 16px;
  733. h3 {
  734. margin: 0;
  735. font-size: 16px;
  736. font-weight: 600;
  737. color: #333;
  738. }
  739. }
  740. .thumbnail-list {
  741. width: 100%;
  742. padding: 0 16px;
  743. box-sizing: border-box;
  744. .thumbnail-item {
  745. position: relative;
  746. margin-bottom: 12px;
  747. cursor: pointer;
  748. border-radius: 8px;
  749. overflow: hidden;
  750. transition: all 0.2s ease;
  751. border: 2px solid rgba(24, 144, 255, 0.2);
  752. &:hover {
  753. transform: translateY(-2px);
  754. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  755. border-color: rgba(24, 144, 255, 0.4);
  756. }
  757. &.active {
  758. border: 2px solid #1890ff;
  759. box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
  760. }
  761. .label {
  762. position: absolute;
  763. top: 8px;
  764. left: 8px;
  765. background-color: rgba(0, 0, 0, 0.6);
  766. color: #fff;
  767. padding: 2px 6px;
  768. border-radius: 4px;
  769. font-size: 12px;
  770. font-weight: 600;
  771. z-index: 1;
  772. }
  773. .thumbnail {
  774. width: 100%;
  775. height: auto;
  776. }
  777. }
  778. }
  779. .page-number {
  780. text-align: center;
  781. margin-top: 16px;
  782. padding: 8px;
  783. background-color: #f0f0f0;
  784. border-radius: 4px;
  785. font-size: 14px;
  786. color: #666;
  787. }
  788. .progress-bar {
  789. margin-top: 12px;
  790. height: 6px;
  791. background-color: #f0f0f0;
  792. border-radius: 3px;
  793. overflow: hidden;
  794. .progress-fill {
  795. height: 100%;
  796. background: linear-gradient(90deg, #1890ff, #40a9ff);
  797. border-radius: 3px;
  798. transition: width 0.3s ease;
  799. }
  800. }
  801. }
  802. .layout-content-center {
  803. flex: 1;
  804. display: flex;
  805. flex-direction: column;
  806. background-color: #000;
  807. }
  808. .viewer-header {
  809. height: 60px;
  810. background-color: #fff;
  811. border-bottom: 1px solid #e0e0e0;
  812. display: flex;
  813. align-items: center;
  814. justify-content: space-between;
  815. padding: 0 24px;
  816. transition: transform 0.3s ease;
  817. &.hidden {
  818. transform: translateY(-100%);
  819. }
  820. .slide-title {
  821. font-size: 18px;
  822. font-weight: 600;
  823. color: #333;
  824. }
  825. .viewer-controls {
  826. display: flex;
  827. gap: 12px;
  828. button {
  829. padding: 8px 12px;
  830. border: 1px solid #d9d9d9;
  831. border-radius: 6px;
  832. background-color: #fff;
  833. color: #333;
  834. cursor: pointer;
  835. transition: all 0.2s ease;
  836. display: flex;
  837. align-items: center;
  838. justify-content: center;
  839. min-width: 40px;
  840. height: 36px;
  841. &:hover:not(:disabled) {
  842. border-color: #1890ff;
  843. color: #1890ff;
  844. }
  845. &:disabled {
  846. opacity: 0.5;
  847. cursor: not-allowed;
  848. }
  849. &.back-btn {
  850. background-color: #1890ff;
  851. color: #fff;
  852. border-color: #1890ff;
  853. &:hover {
  854. background-color: #40a9ff;
  855. border-color: #40a9ff;
  856. }
  857. }
  858. .control-icon {
  859. font-size: 16px;
  860. }
  861. }
  862. }
  863. }
  864. .viewer-canvas {
  865. flex: 1;
  866. position: relative;
  867. background-color: rgb(249, 249, 249);
  868. overflow: hidden;
  869. // 全屏时隐藏滚动条和边框
  870. &.fullscreen-mode {
  871. overflow: hidden !important;
  872. background-color: transparent !important;
  873. }
  874. }
  875. .slide-list-wrap {
  876. position: absolute;
  877. overflow: hidden;
  878. background-color: #fff;
  879. box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
  880. border-radius: 8px;
  881. /* 全屏时去掉圆角 */
  882. .pptist-student-viewer.fullscreen & {
  883. border-radius: 0;
  884. }
  885. }
  886. .loading-indicator {
  887. position: absolute;
  888. top: 50%;
  889. left: 50%;
  890. transform: translate(-50%, -50%);
  891. display: flex;
  892. align-items: center;
  893. justify-content: center;
  894. z-index: 10;
  895. .loading-text {
  896. color: #666;
  897. font-size: 14px;
  898. background-color: rgba(255, 255, 255, 0.9);
  899. padding: 12px 20px;
  900. border-radius: 6px;
  901. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  902. }
  903. }
  904. // 全屏模式样式
  905. .fullscreen-slide {
  906. // 使用放映功能的默认样式
  907. }
  908. // 全屏工具按钮样式,直接复制放映功能的样式
  909. .tools-left {
  910. position: fixed;
  911. bottom: 8px;
  912. left: 8px;
  913. font-size: 25px;
  914. color: #666;
  915. z-index: 10;
  916. .tool-btn {
  917. opacity: .3;
  918. cursor: pointer;
  919. transition: opacity 0.3s;
  920. &:hover {
  921. opacity: .95;
  922. }
  923. &+.tool-btn {
  924. margin-left: 8px;
  925. }
  926. }
  927. }
  928. .tools-right {
  929. height: 66px;
  930. position: fixed;
  931. bottom: -66px;
  932. right: 0;
  933. z-index: 5;
  934. padding: 8px;
  935. transition: bottom 0.3s;
  936. &.visible {
  937. bottom: 0;
  938. }
  939. &::after {
  940. content: '';
  941. width: 100%;
  942. height: 66px;
  943. position: absolute;
  944. left: 0;
  945. top: -66px;
  946. }
  947. .content {
  948. width: 100%;
  949. height: 100%;
  950. display: flex;
  951. justify-content: center;
  952. align-items: center;
  953. border-radius: 4px;
  954. font-size: 25px;
  955. background-color: #fff;
  956. color: #333;
  957. padding: 8px 10px;
  958. box-shadow: 0 2px 12px 0 rgb(56, 56, 56, .2);
  959. border: 1px solid #e2e6ed;
  960. }
  961. .tool-btn {
  962. cursor: pointer;
  963. &:hover,
  964. &.active {
  965. color: #1890ff;
  966. }
  967. &+.tool-btn {
  968. margin-left: 15px;
  969. }
  970. }
  971. .page-number {
  972. font-size: 12px;
  973. padding: 0 12px;
  974. cursor: pointer;
  975. }
  976. }
  977. .viewport {
  978. position: relative;
  979. width: 100%;
  980. height: 100%;
  981. background-color: #fff;
  982. }
  983. .background {
  984. width: 100%;
  985. height: 100%;
  986. background-position: center;
  987. position: absolute;
  988. }
  989. // 响应式设计
  990. @media (max-width: 768px) {
  991. .layout-content-left {
  992. width: 160px;
  993. }
  994. .layout-content-right {
  995. width: 160px;
  996. }
  997. .viewer-header {
  998. padding: 0 16px;
  999. .slide-title {
  1000. font-size: 16px;
  1001. }
  1002. .viewer-controls button {
  1003. padding: 6px 12px;
  1004. font-size: 14px;
  1005. }
  1006. }
  1007. }
  1008. /* 作业提交按钮样式 */
  1009. .homework-submit-btn {
  1010. position: fixed;
  1011. bottom: 30px;
  1012. z-index: 100;
  1013. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1014. color: white;
  1015. padding: 12px 20px;
  1016. border-radius: 25px;
  1017. cursor: pointer;
  1018. display: flex;
  1019. align-items: center;
  1020. gap: 8px;
  1021. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  1022. transition: all 0.3s ease;
  1023. font-size: 14px;
  1024. font-weight: 500;
  1025. &:hover:not(.submitting) {
  1026. transform: translateY(-2px);
  1027. box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
  1028. }
  1029. &:active:not(.submitting) {
  1030. transform: translateY(0);
  1031. }
  1032. &.submitting {
  1033. cursor: not-allowed;
  1034. opacity: 0.8;
  1035. background: linear-gradient(135deg, #999 0%, #666 100%);
  1036. }
  1037. .btn-text {
  1038. white-space: nowrap;
  1039. }
  1040. .tool-btn {
  1041. background: transparent;
  1042. color: white;
  1043. width: 20px;
  1044. height: 20px;
  1045. font-size: 16px;
  1046. &:hover {
  1047. background: transparent;
  1048. transform: none;
  1049. }
  1050. }
  1051. .loading-spinner {
  1052. width: 20px;
  1053. height: 20px;
  1054. border: 2px solid rgba(255, 255, 255, 0.3);
  1055. border-top: 2px solid white;
  1056. border-radius: 50%;
  1057. animation: spin 1s linear infinite;
  1058. }
  1059. }
  1060. @keyframes spin {
  1061. 0% {
  1062. transform: rotate(0deg);
  1063. }
  1064. 100% {
  1065. transform: rotate(360deg);
  1066. }
  1067. }
  1068. </style>