123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643 |
- <template>
- <div class="pptist-student-viewer" :class="{ 'fullscreen': isFullscreen, 'laser-pen': laserPen }">
- <!-- Loading状态显示 -->
- <div v-if="isLoading" class="loading-overlay">
- <div class="loading-content">
- <div class="loading-spinner"></div>
- <div class="loading-text">正在加载课程内容...</div>
- </div>
- </div>
- <!-- 左侧导航栏 -->
- <div class="layout-content-left" v-show="type == '1'" :class="{ collapsed: slidePanelCollapsed }">
- <div class="thumbnails">
- <div class="viewer-header slide-header">
- <h3 v-show="!slidePanelCollapsed">幻灯片导航</h3>
- <button class="collapse-btn" @click="slidePanelCollapsed = !slidePanelCollapsed" :title="slidePanelCollapsed ? '展开' : '收起'">
- <span v-if="slidePanelCollapsed">›</span>
- <span v-else>‹</span>
- </button>
- </div>
- <div v-show="!slidePanelCollapsed">
- <div class="thumbnail-list">
- <div v-for="(slide, index) in slides" :key="slide.id" class="thumbnail-item"
- :class="{ 'active': slideIndex === index }" @click="goToSlide(index)">
- <div class="label">{{ fillDigit(index + 1, 2) }}</div>
- <ThumbnailSlide class="thumbnail" :slide="slide" :size="168" :visible="true" @click="goToSlide(index)" />
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 中间放映区域 -->
- <div class="layout-content-center">
- <div class="viewer-header" :class="{ 'hidden': isFullscreen }">
- <div class="slide-title">幻灯片 {{ slideIndex + 1 }}</div>
- <div class="viewer-controls">
- <button @click="previousSlide" :disabled="slideIndex === 0" title="上一页" v-if="!isFollowModeActive || props.type == '1'">
- <IconLeftTwo class="control-icon" />
- </button>
- <button @click="nextSlide" :disabled="slideIndex === slides.length - 1" title="下一页" v-if="!isFollowModeActive || props.type == '1'">
- <IconRightTwo class="control-icon" />
- </button>
- <!-- <button @click="resetZoom" title="重置缩放">
- <IconUndo class="control-icon" />
- </button> -->
- <button @click="enterFullscreen" title="全屏">
- <IconFullScreenOne class="control-icon" />
- </button>
- <!-- 只有创建人才显示跟随模式按钮 -->
- <button
- v-if="isCreator"
- @click="toggleFollowMode"
- :class="{ 'follow-active': isFollowModeActive }"
- >
- {{ isFollowModeActive ? '关闭跟随模式' : '开启跟随模式' }}
- </button>
- <!-- <button @click="backToEditor" class="back-btn" title="返回编辑">
- <IconEdit class="control-icon" />
- </button> -->
- </div>
- </div>
- <div class="viewer-canvas" ref="viewerCanvasRef">
- <!-- 全屏时:使用放映功能 -->
- <!-- <ScreenSlideList :slideWidth="slideWidth"
- :slideHeight="slideHeight" :animationIndex="0" :turnSlideToId="() => { }"
- :manualExitFullscreen="() => { }" /> -->
- <!-- 不全屏时:使用编辑模式的显示比例和居中逻辑 -->
- <div class="slide-list-wrap" :style="{
- width: isFullscreen ? '100%' : (slideWidth * canvasScale) + 'px',
- height: isFullscreen ? '100%' : (slideHeight * canvasScale) + 'px',
- left: isFullscreen ? '0' : `${(containerWidth - slideWidth * canvasScale) / 2}px`,
- top: isFullscreen ? '0' : `${(containerHeight - slideHeight * canvasScale) / 2}px`
- }">
- <div class="viewport" v-if="false">
- <div class="background" :style="backgroundStyle"></div>
- <ScreenElement v-for="(element, index) in elementList" :key="element.id" :elementInfo="element"
- :elementIndex="index + 1" :animationIndex="0" :turnSlideToId="() => { }"
- :manualExitFullscreen="() => { }" />
- </div>
- <ScreenSlideList :slideWidth="slideWidth * canvasScale" :slideHeight="slideHeight * canvasScale"
- :animationIndex="0" :turnSlideToId="() => { }" :manualExitFullscreen="() => { }" />
- </div>
- <!-- 全屏时的左右下角工具按钮 -->
- <div v-if="isFullscreen && (!isFollowModeActive || props.type == '1')" class="tools-left">
- <IconLeftTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="previousSlide" />
- <IconRightTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="nextSlide" />
- </div>
- <!-- 作业提交按钮 - 当当前幻灯片包含iframe时显示 -->
- <div v-if="currentSlideHasIframe" class="homework-submit-btn" :class="{ 'submitting': isSubmitting }"
- :style="{ right: getHomeworkButtonRight() + 'px' }" @click="handleHomeworkSubmit"
- v-tooltip="isSubmitting ? '作业提交中...' : '作业提交'">
- <IconEdit v-if="!isSubmitting" class="tool-btn" />
- <div v-else class="loading-spinner"></div>
- <span class="btn-text">{{ isSubmitting ? '提交中...' : '作业提交' }}</span>
- </div>
- <!-- 刷新iframe按钮 -->
- <div class="refresh-page-btn"
- v-if="currentSlideHasIframe"
- :style="{ right: getRefreshButtonRight() + 'px' }"
- @click="handleRefreshPage"
- v-tooltip="'刷新iframe内容'">
- <Refresh class="tool-btn" />
- <span class="btn-text">刷新</span>
- </div>
- <!-- 功能组件 -->
- <SlideThumbnails v-if="slideThumbnailModelVisible" :turnSlideToIndex="goToSlide"
- @close="slideThumbnailModelVisible = false" />
- <WritingBoardTool :slideWidth="slideWidth" :slideHeight="slideHeight" v-if="writingBoardToolVisible"
- @close="writingBoardToolVisible = false" />
- <CountdownTimer v-if="timerlVisible" @close="timerlVisible = false" />
- <div v-if="isFullscreen && (!isFollowModeActive || props.type == '1')" class="tools-right" :class="{ 'visible': rightToolsVisible }"
- @mouseleave="rightToolsVisible = false" @mouseenter="rightToolsVisible = true">
- <div class="content">
- <div class="tool-btn page-number" @click="slideThumbnailModelVisible = true">幻灯片 {{ slideIndex +
- 1 }} / {{ slides.length }}</div>
- <IconWrite class="tool-btn" v-tooltip="'画笔工具'" @click="writingBoardToolVisible = true" />
- <IconMagic class="tool-btn" v-tooltip="'激光笔'" :class="{ 'active': laserPen }" @click="toggleLaserPen" />
- <IconStopwatchStart class="tool-btn" v-tooltip="'计时器'" @click="timerlVisible = !timerlVisible" />
- <IconOffScreenOne class="tool-btn" v-tooltip="'退出全屏'" @click="enterFullscreen" />
- </div>
- </div>
- </div>
- </div>
- <div class="layout-content-right" v-show="type == '1'" :class="{ collapsed: workPanelCollapsed }">
- <div class="thumbnails">
- <div class="viewer-header right-panel-header">
- <h3 v-show="!workPanelCollapsed">{{
- rightPanelMode === 'homework' ? '作业区' :
- rightPanelMode === 'dialogue' ? '对话区' : '统计'
- }}</h3>
- <button class="collapse-btn" @click="workPanelCollapsed = !workPanelCollapsed" :title="workPanelCollapsed ? '展开' : '收起'">
- <span v-if="workPanelCollapsed">›</span>
- <span v-else>‹</span>
- </button>
- </div>
-
- <!-- 侧边导航标签 - 无论展开还是收缩都显示在左侧 -->
- <div class="side-nav-tabs">
- <button
- v-if="currentSlideHasIframe"
- class="side-nav-btn"
- :class="{ active: rightPanelMode === 'homework' }"
- @click="switchToHomework"
- title="作业"
- >
- <img :src="rightPanelMode === 'homework' ? homeworkActiveIcon : homeworkIcon" alt="作业">
- </button>
- <button
- v-if="isChoiceQuestion"
- class="side-nav-btn"
- :class="{ active: rightPanelMode === 'choice' }"
- @click="switchToChoice"
- title="统计"
- >
- <img :src="rightPanelMode === 'choice' ? choiceActiveIcon : choiceIcon" alt="统计">
- </button>
- <button
- class="side-nav-btn"
- :class="{ active: rightPanelMode === 'dialogue' }"
- @click="switchToDialogue"
- title="对话"
- >
- <img :src="rightPanelMode === 'dialogue' ? dialogueActiveIcon : dialogueIcon" alt="对话">
- </button>
- </div>
-
-
- <!-- 作业区内容 -->
- <div v-show="!workPanelCollapsed && rightPanelMode === 'homework'" class="panel-content">
- <div class="homework-title">已提交</div>
- <div v-if="workLoading" class="homework-loading">正在加载作业...</div>
- <div v-else>
- <div v-if="workArray && workArray.length" class="homework-grid">
- <button class="homework-btn" v-for="(work, idx) in workArray" :key="work.id ?? idx" :title="work.name" @click="openWorkModal(work)">
- <span class="homework-btn__text">{{ work.name }}</span>
- </button>
- </div>
- <div class="homework-empty" v-else>
- 暂无作业提交
- </div>
- </div>
-
- <!-- 未提交作业的学生列表 -->
- <div v-if="unsubmittedStudents && unsubmittedStudents.length > 0" class="homework-title" style="margin-top: 20px;">未提交</div>
- <div v-if="unsubmittedStudents && unsubmittedStudents.length > 0">
- <div v-if="studentLoading" class="homework-loading">正在加载学生信息...</div>
- <div v-else>
- <div class="homework-grid">
- <button class="homework-btn unsubmitted" v-for="(student, idx) in unsubmittedStudents" :key="student.id ?? idx" :title="student.name" disabled>
- <span class="homework-btn__text">{{ student.name }}</span>
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 对话区内容 -->
- <div v-show="!workPanelCollapsed && rightPanelMode === 'dialogue'" class="panel-content">
- <DialoguePanel :userid="props.userid" :courseid="props.courseid"/>
- </div>
-
- <!-- 选择题统计内容 -->
- <div v-show="!workPanelCollapsed && rightPanelMode === 'choice'" class="panel-content">
- <ChoiceStatistics :workArray="workArray" :elementList="elementList" />
- </div>
- </div>
- </div>
- </div>
- <ShotWorkModal v-model:visible="visibleShot" :work="selectedWork" />
- <QAWorkModal v-model:visible="visibleQA" :work="selectedWork" />
- <ChoiceWorkModal v-model:visible="visibleChoice" :work="selectedWork" />
- <AIWorkModal v-model:visible="visibleAI" :work="selectedWork" />
- </template>
- <script lang="ts" setup>
- import { computed, ref, onMounted, onUnmounted, nextTick, inject, watch } from 'vue'
- import { storeToRefs } from 'pinia'
- import { useSlidesStore } from '@/store'
- import { ElementTypes } from '@/types/slides'
- import { fillDigit } from '@/utils/common'
- import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
- import ScreenSlideList from '@/views/Screen/ScreenSlideList.vue'
- import ScreenElement from '@/views/Screen/ScreenElement.vue'
- import SlideThumbnails from '@/views/Screen/SlideThumbnails.vue'
- import WritingBoardTool from '@/views/Screen/WritingBoardTool.vue'
- import CountdownTimer from '@/views/Screen/CountdownTimer.vue'
- import useSlideBackgroundStyle from '@/hooks/useSlideBackgroundStyle'
- import useImport from '@/hooks/useImport'
- import message from '@/utils/message'
- import api from '@/services/course'
- import ShotWorkModal from './components/ShotWorkModal.vue'
- import QAWorkModal from './components/QAWorkModal.vue'
- import ChoiceWorkModal from './components/ChoiceWorkModal.vue'
- import AIWorkModal from './components/AIWorkModal.vue'
- import DialoguePanel from './components/DialoguePanel.vue'
- import ChoiceStatistics from './components/ChoiceStatistics.vue'
- import * as Y from 'yjs'
- import { WebsocketProvider } from 'y-websocket'
- import { Refresh } from '@icon-park/vue-next'
- // 导入图片资源
- import homeworkIcon from '@/assets/img/homework.png'
- import homeworkActiveIcon from '@/assets/img/homework-active.png'
- import dialogueIcon from '@/assets/img/dialogue.png'
- import dialogueActiveIcon from '@/assets/img/dialogue-active.png'
- import choiceIcon from '@/assets/img/choice.png'
- import choiceActiveIcon from '@/assets/img/choice-active.png'
- // 定义组件props
- interface Props {
- courseid?: string | null
- userid?: string | null
- oid?: string | null
- org?: string | null
- cid?: string | null
- type?: string | null
- }
- const props = withDefaults(defineProps<Props>(), {
- courseid: null,
- userid: null,
- oid: null,
- org: null,
- cid: null,
- type: null,
- })
- // 图标组件通过全局注册,无需导入
- const slidesStore = useSlidesStore()
- const { slides, slideIndex, currentSlide, viewportSize, viewportRatio } = storeToRefs(slidesStore)
- // 添加容器引用,用于计算幻灯片尺寸
- const viewerCanvasRef = ref<HTMLElement>()
- // 放映相关的状态
- const canvasScale = ref(1) // 画布缩放比例
- const isFullscreen = ref(false) // 是否全屏
- const containerWidth = ref(0) // 容器宽度
- const containerHeight = ref(0) // 容器高度
- // 全屏工具相关状态
- const rightToolsVisible = ref(false)
- const writingBoardToolVisible = ref(false)
- const timerlVisible = ref(false)
- const slideThumbnailModelVisible = ref(false)
- const laserPen = ref(false)
- // 作业提交状态
- const isSubmitting = ref(false)
- // 控制组件显示的开关
- const showSlideList = ref(true)
- const slideWidth = ref(0)
- const slideHeight = ref(0)
- // 添加loading状态
- const isLoading = ref(false)
- const workLoading = ref(false)
- const studentLoading = ref(false)
- // 作业数组
- type WorkItem = {
- id?: string | number
- name: string
- type: number | string
- [key: string]: any
- }
- const workArray = ref<WorkItem[]>([])
- // 作业弹窗相关
- const selectedWork = ref<any>(null)
- const visibleShot = ref(false)
- const visibleQA = ref(false)
- const visibleChoice = ref(false)
- const visibleAI = ref(false)
- // 作业区收缩状态
- const workPanelCollapsed = ref(false)
- // 幻灯片导航收缩状态
- const slidePanelCollapsed = ref(false)
- // 右侧面板当前显示的内容:'homework' | 'dialogue' | 'choice'
- const rightPanelMode = ref<'homework' | 'dialogue' | 'choice'>('homework')
- // 移除定时器相关代码,改用socket监听
- const courseDetail = ref<any>({})
- const studentArray = ref<any>([])
- // 跟随模式相关状态
- const isCreator = ref(false) // 是否为创建人
- const isFollowModeActive = ref(false) // 跟随模式是否开启
- // 计算未提交作业的学生
- const unsubmittedStudents = computed(() => {
- if (!studentArray.value || !workArray.value) return []
-
- // 获取已提交作业的学生姓名
- const submittedNames = workArray.value.map(work => work.name)
-
- // 过滤出未提交作业的学生
- return studentArray.value.filter((student: any) => !submittedNames.includes(student.name))
- })
- const docSocket = ref<Y.Doc | null>(null)
- const yMessage = ref<any | null>(null)
- const providerSocket = ref<WebsocketProvider | null>(null)
- const mId = ref<string | null>(null)
- // 切换到作业区
- const switchToHomework = () => {
- rightPanelMode.value = 'homework'
- if (workPanelCollapsed.value) {
- workPanelCollapsed.value = false
- }
- }
- // 切换到对话区
- const switchToDialogue = () => {
- rightPanelMode.value = 'dialogue'
- if (workPanelCollapsed.value) {
- workPanelCollapsed.value = false
- }
- }
- // 切换到选择题统计
- const switchToChoice = () => {
- rightPanelMode.value = 'choice'
- if (workPanelCollapsed.value) {
- workPanelCollapsed.value = false
- }
- }
- // 自动切换到可用的面板
- const autoSwitchToAvailablePanel = () => {
- // 如果当前在作业区但没有iframe,自动切换到其他可用面板
- if (rightPanelMode.value === 'homework' && !currentSlideHasIframe.value) {
- if (isChoiceQuestion.value) {
- rightPanelMode.value = 'choice'
- console.log('自动切换到统计面板')
- }
- else {
- rightPanelMode.value = 'dialogue'
- console.log('自动切换到对话面板')
- }
- }
- // 如果当前在统计面板但不是选择题,自动切换到对话面板
- else if (rightPanelMode.value === 'choice' && !isChoiceQuestion.value) {
- rightPanelMode.value = 'dialogue'
- console.log('自动切换到对话面板')
- }
- }
- // 移除定时器相关函数,改用socket监听
- // 收缩/展开后重新计算中间画布尺寸(在 DOM 更新并完成过渡后)
- watch([() => workPanelCollapsed.value, () => slidePanelCollapsed.value], async () => {
- // 等待本次 DOM 更新
- await nextTick()
- // 先在下一帧计算一次,确保初步布局就绪
- requestAnimationFrame(() => {
- calculateScale()
- })
- // 再在过渡结束后(与左右栏 width .2s 过渡一致)复算一次,确保最终尺寸
- setTimeout(() => {
- calculateScale()
- }, 220)
- }, { flush: 'post' })
- const openWorkModal = (work: WorkItem) => {
- selectedWork.value = work
- const t = Number(work?.type)
- // if (t !== 1) {
- // message.warning('暂未开发完成')
- // return
- // }
- visibleShot.value = false
- visibleQA.value = false
- visibleChoice.value = false
- visibleAI.value = false
- if (t === 1) {
- visibleShot.value = true
- }
- else if (t === 3) {
- visibleQA.value = true
- }
- else if (t === 8) {
- visibleChoice.value = true
- }
- else if (t === 20) {
- visibleAI.value = true
- }
- else {
- message.info('暂不支持的作业类型')
- }
- }
- // 计算幻灯片尺寸的函数
- const calculateSlideSize = () => {
- const slideWrapRef = isFullscreen.value ? document.body : viewerCanvasRef.value
- const winWidth = slideWrapRef?.clientWidth || 0
- const winHeight = slideWrapRef?.clientHeight || 0
- // 根据视口比例计算最佳尺寸
- if (winHeight / winWidth === viewportRatio.value) {
- slideWidth.value = winWidth
- slideHeight.value = winHeight
- }
- else if (winHeight / winWidth > viewportRatio.value) {
- slideWidth.value = winWidth
- slideHeight.value = winWidth * viewportRatio.value
- }
- else {
- slideWidth.value = winHeight / viewportRatio.value
- slideHeight.value = winHeight
- }
- console.log('calculateSlideSize', slideWidth.value, slideHeight.value, viewportRatio.value, canvasScale.value)
- }
- // 使用编辑模式的缩放逻辑
- const calculateScale = () => {
- console.log('calculateScale 开始执行')
- // 获取容器尺寸
- const container = viewerCanvasRef.value || document.querySelector('.viewer-canvas')
- if (container) {
- containerWidth.value = container.clientWidth
- containerHeight.value = container.clientHeight
- console.log('容器尺寸:', {
- width: containerWidth.value,
- height: containerHeight.value
- })
- // 计算基础尺寸
- const baseWidth = viewportSize.value
- const baseHeight = viewportSize.value * viewportRatio.value
- console.log('基础尺寸:', {
- baseWidth,
- baseHeight,
- viewportSize: viewportSize.value,
- viewportRatio: viewportRatio.value
- })
- // 计算缩放比例,让幻灯片能够合理利用空间
- const scaleX = containerWidth.value / baseWidth
- const scaleY = containerHeight.value / baseHeight
- console.log('原始缩放比例:', { scaleX, scaleY })
- // 选择较小的缩放比例,确保幻灯片完全显示且居中,留10%边距
- const scale = Math.min(scaleX, scaleY) * 0.9
- console.log('最终缩放比例:', scale)
- canvasScale.value = isFullscreen.value ? 1 : props.type == '1' ? 1 : 1
- // canvasScale.value = 1
- }
- else {
- console.error('找不到容器元素')
- }
- // 计算幻灯片尺寸
- nextTick(() => {
- setTimeout(() => {
- calculateSlideSize()
- }, 500)
- })
- }
- // 简化:直接使用放映功能的缩放逻辑
- const resetZoom = () => {
- calculateScale()
- }
- // 背景样式
- const background = computed(() => currentSlide.value?.background)
- const { backgroundStyle } = useSlideBackgroundStyle(background)
- // 计算当前幻灯片的元素列表
- const elementList = computed(() => {
- return currentSlide.value?.elements || []
- })
- // 检查当前是否为选择题(toolType为45)
- const isChoiceQuestion = computed(() => {
- const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
- return frame?.toolType === 45
- })
- // 检测当前幻灯片是否包含iframe元素
- const currentSlideHasIframe = computed(() => {
- console.log('elementList.value', elementList.value)
- return elementList.value.some(element => element.type === ElementTypes.FRAME)
- })
- // 跳转到指定幻灯片
- const goToSlide = (index: number) => {
- console.log('goToSlide 被调用,目标索引:', index)
- console.log('当前索引:', slideIndex.value)
-
- if (index >= 0 && index < slides.value.length) {
- slidesStore.updateSlideIndex(index)
- console.log('更新后的索引:', slideIndex.value)
- }
- else {
- console.warn('goToSlide: 无效的索引:', index)
- }
- }
- // 上一页
- const previousSlide = () => {
- if (slideIndex.value > 0) {
- const newIndex = slideIndex.value - 1
- console.log('上一页,从', slideIndex.value, '到', newIndex)
- slidesStore.updateSlideIndex(newIndex)
- }
- }
- // 下一页
- const nextSlide = () => {
- if (slideIndex.value < slides.value.length - 1) {
- const newIndex = slideIndex.value + 1
- console.log('下一页,从', slideIndex.value, '到', newIndex)
- slidesStore.updateSlideIndex(newIndex)
- }
- }
- // 监听slideIndex变化,调用getWork
- watch(() => slideIndex.value, (newIndex, oldIndex) => {
- console.log('slideIndex变化,调用getWork', { newIndex, oldIndex })
- if (newIndex !== oldIndex && typeof newIndex === 'number') {
- // 检查新页面是否有iframe
- const hasIframe = currentSlideHasIframe.value
-
- if (hasIframe) {
- console.log('当前页面有iframe,获取作业数据')
- console.log('触发getWork,当前幻灯片索引:', newIndex)
- getWork()
- }
-
- if (props.type == '1' && isFollowModeActive.value && isCreator.value) {
- api.updateCourseFollowC(newIndex, props.courseid as string)
- sendMessage({slideIndex: newIndex, courseid: props.courseid, type: 'slideIndex'})
- }
- // 自动切换到可用的面板
- autoSwitchToAvailablePanel()
- }
- }, { immediate: false, deep: false })
- // 监听iframe状态变化,自动切换面板
- watch(() => currentSlideHasIframe.value, (hasIframe) => {
- if (!hasIframe) {
- autoSwitchToAvailablePanel()
- }
- }, { immediate: false })
- // 全屏
- const enterFullscreen = () => {
- if (document.fullscreenElement) {
- document.exitFullscreen()
- }
- else {
- document.documentElement.requestFullscreen()
- }
- }
- // 监听全屏状态变化
- const handleFullscreenChange = () => {
- isFullscreen.value = !!document.fullscreenElement
- if (isFullscreen.value) {
- // 全屏时不需要计算缩放,直接使用放映功能
- console.log('进入全屏模式')
- }
- else {
- // 退出全屏时重置所有工具状态并重新计算缩放比例
- console.log('退出全屏模式,重置工具状态')
- // 重置所有工具状态
- rightToolsVisible.value = false
- writingBoardToolVisible.value = false
- timerlVisible.value = false
- slideThumbnailModelVisible.value = false
- laserPen.value = false
- // 重新计算缩放比例
- nextTick(() => {
- setTimeout(() => {
- calculateScale()
- }, 1000)
- })
- }
- }
- // 切换激光笔模式
- const toggleLaserPen = () => {
- laserPen.value = !laserPen.value
- console.log('激光笔状态:', laserPen.value ? '开启' : '关闭')
- }
- // 获取导入导出功能
- const { readJSON, exportJSON2, getFile } = useImport()
- // 根据iframe的URL查找对应的幻灯片索引
- const findSlideIndexByIframeUrl = (iframeUrl: string): number => {
- try {
- console.log('查找iframe对应的幻灯片索引,iframe URL:', iframeUrl)
- // 遍历所有幻灯片,查找包含该iframe URL的幻灯片
- for (let i = 0; i < slides.value.length; i++) {
- const slide = slides.value[i]
- // 检查幻灯片的元素中是否有iframe
- if (slide.elements && slide.elements.length > 0) {
- for (const element of slide.elements) {
- // 检查是否是iframe元素
- if (element.type === ElementTypes.FRAME) {
- // 检查iframe的src是否匹配
- if (element.url === iframeUrl) {
- console.log(`找到匹配的幻灯片,索引: ${i}, 幻灯片ID: ${slide.id}`)
- return i
- }
- }
- }
- }
- }
- // 如果没有找到匹配的幻灯片,返回当前幻灯片索引
- console.log('未找到匹配的幻灯片,使用当前幻灯片索引:', slideIndex.value)
- return slideIndex.value
- }
- catch (error) {
- console.error('查找幻灯片索引时出错:', error)
- return slideIndex.value
- }
- }
- // 处理iframe链接,为包含workPage的iframe添加必要参数
- // 处理iframe链接,为包含workPage的iframe添加必要参数
- const processIframeLinks = async () => {
- try {
- console.log('开始处理iframe链接')
- console.log('当前props:', { courseid: props.courseid, userid: props.userid })
- // 从slides数据中查找包含iframe的元素
- let hasIframe = false
- // 由于有异步操作,需整体用Promise.all处理
- const updatedSlides = await Promise.all(
- slides.value.map(async (slide, slideIndex) => {
- if (slide.elements && slide.elements.length > 0) {
- // 这里不能直接用async map,否则会导致类型不对
- const updatedElements = await Promise.all(
- slide.elements.map(async (element) => {
- // 检查是否是iframe元素
- if (element.type === ElementTypes.FRAME && element.url) {
- const iframeSrc = element.url
- const toolType = element.toolType
- if (iframeSrc.includes('workPage')) {
- hasIframe = true
- console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
- try {
- // 解析URL,处理hash部分
- let baseUrl = iframeSrc
- let hashPart = ''
- // 分离base URL和hash部分
- if (iframeSrc.includes('#')) {
- const parts = iframeSrc.split('#')
- baseUrl = parts[0]
- hashPart = parts[1]
- }
- // 构建新的hash部分,添加参数
- // 使用当前幻灯片索引作为task参数
- let newHash = hashPart
- if (newHash.includes('?')) {
- // 如果hash中已经有查询参数,添加&
- newHash += `&courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
- }
- else {
- // 如果hash中没有查询参数,添加?
- newHash += `?courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
- }
- // 构建新的URL
- const newUrl = `${baseUrl}#${newHash}`
- console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
- // 返回更新后的元素
- return {
- ...element,
- url: newUrl
- }
- }
- catch (error) {
- console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
- return element
- }
- }
- else if (toolType == 73) {
- hasIframe = true
-
- // 先尝试获取iframe的contentWindow,如果获取不到再使用HTML方式
- try {
- // 创建一个临时的iframe来测试是否能获取contentWindow
- const tempIframe = document.createElement('iframe')
- tempIframe.style.display = 'none'
- tempIframe.src = iframeSrc
-
- // 先将临时iframe添加到body,否则onload事件不会触发
- document.body.appendChild(tempIframe)
- // 等待iframe加载完成
- await new Promise((resolve, reject) => {
- tempIframe.onload = resolve
- tempIframe.onerror = reject
- // 可选:设置超时时间,避免长时间无响应
- setTimeout(() => reject(new Error('Timeout')), 5000)
- })
-
- // 尝试获取contentWindow
- if (tempIframe.contentWindow && tempIframe.contentWindow.document) {
- console.log(`iframe ${iframeSrc} 可以获取contentWindow,使用直接加载方式`)
- // 移除临时iframe
- document.body.removeChild(tempIframe)
-
- return {
- ...element,
- isHTML: false,
- url: iframeSrc
- }
- }
- // 加载完成但无法获取contentWindow,也要移除iframe
- document.body.removeChild(tempIframe)
-
- }
- catch (error) {
- console.log(`iframe ${iframeSrc} 无法获取contentWindow,使用HTML方式:`, error)
- }
-
- // 如果无法获取contentWindow,使用HTML方式
- const html = await api.getHTML(iframeSrc)
- // const html = await api.getHTML('https://knowledge.cocorobo.cn/zh-CN/story-telling/a7fa08b8-cf60-11ef-93e3-12e77c4cb76b')
- console.log('html', html)
- console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
- return {
- ...element,
- isHTML: true,
- url: html
- }
- }
- }
- // 不是iframe元素或不需要处理,直接返回
- return element
- })
- )
- // 返回更新后的幻灯片
- return {
- ...slide,
- elements: updatedElements
- }
- }
- // 没有元素的幻灯片直接返回
- return slide
- })
- )
- if (hasIframe) {
- console.log('找到iframe元素,更新slides数据')
- // 更新store中的slides数据
- slidesStore.setSlides(updatedSlides)
- console.log('slides数据更新完成')
- }
- else {
- console.log('未找到包含workPage的iframe元素')
- }
- console.log('iframe链接处理完成')
- }
- catch (error) {
- console.error('处理iframe链接时出错:', error)
- }
- }
- // 导入JSON功能
- const importJSON = (jsonData: any) => {
- try {
- console.log('Student importJSON 开始执行')
- const result = readJSON(jsonData, true)
- if (result.success) {
- console.log('Student importJSON 成功,开始重新渲染')
- // 强制重新渲染:先隐藏组件
- showSlideList.value = false
- // 重新计算画布尺寸和缩放比例
- nextTick(() => {
- calculateScale()
- // 延迟500ms后重新显示组件,确保重新渲染完成
- setTimeout(() => {
- showSlideList.value = true
- // 只有当当前页面存在iframe时才获取作业数据
- if (currentSlideHasIframe.value && props.type == '1') {
- getWork()
- }
- selectCourseSLook()
- console.log('组件重新渲染完成')
- }, 500)
- })
- return true
- }
- console.error('Student importJSON 失败:', result.error)
- return false
- }
- catch (error) {
- console.error('Student importJSON 执行失败:', error)
- return false
- }
- }
- // 导出JSON功能
- const exportJSON = () => {
- try {
- console.log('Student exportJSON 开始执行,调用 useImport.exportJSON2')
- // 直接调用 useImport 中的 exportJSON2 函数
- const exportData = exportJSON2()
- if (exportData) {
- return exportData
- }
- console.error('Student exportJSON 失败: exportJSON2 返回空数据')
- return false
- }
- catch (error) {
- console.error('Student exportJSON 执行失败:', error)
- return false
- }
- }
- // 返回编辑器
- const backToEditor = () => {
- // 通过路由跳转到编辑模式
- window.location.href = '/'
- }
- const submitWork = async (slideIndex: number, atool: string, content: string, type: string) => {
- const res = await api.submitWork({
- uid: props.userid as string,
- cid: props.courseid as string,
- stage: '0',
- task: String(slideIndex), // 转为字符串
- tool: '0',
- atool: atool,
- content: content,
- type: type
- })
- getWork()
- console.log(res)
- }
- // 文件上传到AWS S3的函数
- const uploadFile = (file: File): Promise<string> => {
- return new Promise((resolve, reject) => {
- try {
- // 检查AWS SDK是否可用
- if (!(window as any).AWS) {
- reject(new Error('AWS SDK not loaded'))
- return
- }
- const credentials = {
- accessKeyId: 'AKIATLPEDU37QV5CHLMH',
- secretAccessKey: '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'
- }
- // 配置AWS
- ;(window as any).AWS.config.update(credentials)
- ;(window as any).AWS.config.region = 'cn-northwest-1'
- // 创建S3实例
- const bucket = new (window as any).AWS.S3({ params: { Bucket: 'ccrb' } })
- if (file) {
- // 生成唯一的文件名
- const fileExtension = file.name.split('.').pop()
- const fileName = `${file.name.split('.')[0]}_${Date.now()}.${fileExtension}`
- const params = {
- Key: fileName,
- ContentType: file.type,
- Body: file,
- ACL: 'public-read'
- }
- const options = {
- partSize: 2048 * 1024 * 1024, // 2GB分片
- queueSize: 2,
- leavePartsOnError: true
- }
- bucket
- .upload(params, options)
- .on('httpUploadProgress', (evt: any) => {
- // 这里可以添加进度条逻辑
- const progress = Math.round((evt.loaded * 100) / evt.total)
- console.log(`Uploaded: ${progress}%`)
- })
- .send((err: any, data: any) => {
- if (err) {
- console.error('Upload failed:', err)
- message.error('文件上传失败')
- reject(err)
- }
- else {
- console.log('Upload successful:', data.Location)
- resolve(data.Location)
- }
- })
- }
- else {
- reject(new Error('No file provided'))
- }
- }
- catch (error) {
- console.error('Upload error:', error)
- reject(error)
- }
- })
- }
- // 作业提交功能(优化版)
- const handleHomeworkSubmit = async () => {
- console.log('作业提交按钮被点击')
- // 防抖:如果正在提交中,直接返回
- if (isSubmitting.value) {
- console.log('作业正在提交中,忽略重复点击')
- return
- }
- isSubmitting.value = true
- try {
- // 获取所有iframe元素
- const iframes = document.querySelectorAll('.viewer-canvas .screen-slide')[slideIndex.value].querySelectorAll('iframe')
- console.log('找到iframe元素数量:', iframes.length)
- if (iframes.length === 0) {
- message.warning('当前页面没有找到iframe元素')
- return
- }
- let hasSubmitWork = false
- for (let i = 0; i < iframes.length; i++) {
- const iframe = iframes[i] as HTMLIFrameElement
- const iframeSrc = iframe.src
- console.log(`iframe ${i + 1} 链接:`, iframeSrc)
- // 检查iframe链接是否包含workPage
- if (iframeSrc && iframeSrc.includes('workPage')) {
- console.log('找到包含workPage的iframe,尝试执行submitWork')
- try {
- const iframeWindow = iframe.contentWindow as Window & { submitWork?: (...args: any[]) => unknown }
- if (iframeWindow && typeof iframeWindow.submitWork === 'function') {
- console.log('执行iframe中的submitWork方法,参数可变')
- const iframeSlideIndex = slideIndex.value
- const submitArgs = [iframeSlideIndex]
- // 支持同步和异步submitWork
- const result = await iframeWindow.submitWork(...submitArgs)
- if (result instanceof Promise) {
- await result
- console.log('submitWork异步执行完成')
- }
- else {
- console.log('submitWork同步执行完成')
- message.success('作业提交成功')
- hasSubmitWork = true
-
- // 发送作业提交成功的socket消息
- sendMessage({
- type: 'homework_submitted',
- courseid: props.courseid,
- slideIndex: slideIndex.value,
- userid: props.userid
- })
- }
- break
- }
- else {
- console.log('iframe中没有找到submitWork方法')
- }
- }
- catch (error) {
- console.error('访问iframe内容时出错:', error)
- }
- }
- else if (iframeSrc && (iframeSrc.includes('aichat.cocorobo') || iframeSrc.includes('knowledge.cocorobo'))) {
- console.log('找到包含aichat.cocorobo或knowledge.cocorobo的iframe,尝试执行submitWork')
- // 由于TS类型检查,需通过 any 绕过类型限制
- const iframeWindow = iframe.contentWindow as any
- if (iframeWindow && iframeWindow.exposed_outputs) {
- console.log('执行iframe中的submitWork方法,参数可变')
- const iframeSlideIndex = slideIndex.value
- const Cow = JSON.stringify(iframeWindow.exposed_outputs)
- // 这里假设 submitWork 是全局可用的函数
- await submitWork(iframeSlideIndex, '72', Cow, '20')
- message.success('作业提交成功')
- hasSubmitWork = true
-
- // 发送作业提交成功的socket消息
- sendMessage({
- type: 'homework_submitted',
- courseid: props.courseid,
- slideIndex: slideIndex.value,
- userid: props.userid
- })
- }
- }
- else {
- // message.warning('当前页面暂不支持作业提交')
- console.log('尝试截图当前页面并提交')
- // return
- try {
- // 尝试使用html2canvas,对iframe支持更好
- let imageData: string
- const screenSlides = document.querySelectorAll('.viewer-canvas .screen-slide')
- let iframeElement: HTMLElement | null = null
- if (
- screenSlides &&
- screenSlides[slideIndex.value] &&
- screenSlides[slideIndex.value].querySelector('iframe') &&
- (screenSlides[slideIndex.value].querySelector('iframe') as HTMLIFrameElement).contentWindow &&
- (screenSlides[slideIndex.value].querySelector('iframe') as HTMLIFrameElement).contentWindow!.document &&
- (screenSlides[slideIndex.value].querySelector('iframe') as HTMLIFrameElement).contentWindow!.document.body
- ) {
- iframeElement = (screenSlides[slideIndex.value].querySelector('iframe') as HTMLIFrameElement).contentWindow!.document.body as HTMLElement
- }
- else {
- throw new Error('未能获取到iframe的body元素,无法截图')
- }
- try {
- // 方案1:使用html2canvas的iframe特殊处理
- const html2canvas = await import('html2canvas')
- const canvas = await html2canvas.default(iframeElement, {
- useCORS: true,
- allowTaint: true,
- scale: 1,
- backgroundColor: '#ffffff',
- logging: false,
- // 关键配置:处理iframe内容
- foreignObjectRendering: true,
- removeContainer: true,
- // 特殊处理iframe
- onclone: (clonedDoc, element) => {
- // 在克隆的文档中处理iframe
- const clonedIframes = clonedDoc.querySelectorAll('iframe')
- clonedIframes.forEach((iframe) => {
- const src = iframe.getAttribute('src')
- if (src) {
- // 创建一个包含iframe信息的占位符
- const placeholder = clonedDoc.createElement('div')
- placeholder.style.width = iframe.style.width || iframe.getAttribute('width') || '100%'
- placeholder.style.height = iframe.style.height || iframe.getAttribute('height') || '300px'
- placeholder.style.backgroundColor = '#f8f9fa'
- placeholder.style.border = '2px solid #dee2e6'
- placeholder.style.borderRadius = '8px'
- placeholder.style.display = 'flex'
- placeholder.style.flexDirection = 'column'
- placeholder.style.alignItems = 'center'
- placeholder.style.justifyContent = 'center'
- placeholder.style.padding = '20px'
- placeholder.style.fontFamily = 'Arial, sans-serif'
-
- // 创建iframe占位符内容
- placeholder.innerHTML = `
- <div style="font-size: 24px; margin-bottom: 10px;">🌐</div>
- <div style="font-size: 16px; font-weight: bold; color: #495057; margin-bottom: 8px;">iframe内容</div>
- <div style="font-size: 12px; color: #6c757d; text-align: center; word-break: break-all; max-width: 100%;">
- ${src.length > 60 ? src.substring(0, 60) + '...' : src}
- </div>
- <div style="font-size: 10px; color: #adb5bd; margin-top: 8px;">
- (跨域限制,无法截图内部内容)
- </div>
- `
-
- // 替换iframe
- iframe.parentNode?.replaceChild(placeholder, iframe)
- }
- })
- }
- })
- imageData = canvas.toDataURL('image/png', 0.95)
- console.log('使用html2canvas iframe特殊处理截图成功')
- }
- catch (html2canvasError) {
- console.log('html2canvas iframe处理失败,尝试html-to-image:', html2canvasError)
-
- try {
- // 方案2:使用html-to-image
- const { toPng } = await import('html-to-image')
- imageData = await toPng(iframeElement, {
- quality: 0.95,
- backgroundColor: '#ffffff',
- // html-to-image的iframe处理
- filter: (node) => {
- // 过滤掉可能引起问题的元素
- if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
- return false
- }
- return true
- }
- })
- console.log('使用html-to-image截图成功')
- }
- catch (htmlToImageError) {
- console.log('html-to-image也失败了,使用canvas绘制方案:', htmlToImageError)
-
- // 方案3:使用canvas直接绘制iframe占位符
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- if (ctx) {
- // 设置canvas尺寸
- canvas.width = iframeElement.offsetWidth || 800
- canvas.height = iframeElement.offsetHeight || 600
-
- // 绘制背景
- const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height)
- gradient.addColorStop(0, '#f8f9fa')
- gradient.addColorStop(1, '#e9ecef')
- ctx.fillStyle = gradient
- ctx.fillRect(0, 0, canvas.width, canvas.height)
-
- // 绘制边框
- ctx.strokeStyle = '#dee2e6'
- ctx.lineWidth = 3
- ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4)
-
- // 绘制内边框
- ctx.strokeStyle = '#ffffff'
- ctx.lineWidth = 1
- ctx.strokeRect(5, 5, canvas.width - 10, canvas.height - 10)
-
- // 绘制iframe图标
- ctx.fillStyle = '#6c757d'
- ctx.font = 'bold 48px Arial'
- ctx.textAlign = 'center'
- ctx.fillText('', canvas.width / 2, canvas.height / 2 - 40)
-
- // 绘制标题
- ctx.font = 'bold 20px Arial'
- ctx.fillStyle = '#495057'
- ctx.fillText('iframe内容', canvas.width / 2, canvas.height / 2 + 20)
-
- // 绘制URL
- const src = iframeElement.src
- if (src) {
- ctx.font = '14px Arial'
- ctx.fillStyle = '#6c757d'
- const url = src.length > 80 ? src.substring(0, 80) + '...' : src
- ctx.fillText(url, canvas.width / 2, canvas.height / 2 + 50)
- }
-
- // 绘制提示信息
- ctx.font = '12px Arial'
- ctx.fillStyle = '#adb5bd'
- ctx.fillText('(跨域限制,无法截图内部内容)', canvas.width / 2, canvas.height / 2 + 80)
-
- // 绘制装饰性元素
- ctx.strokeStyle = '#dee2e6'
- ctx.lineWidth = 1
- ctx.setLineDash([5, 5])
- ctx.strokeRect(20, 20, canvas.width - 40, canvas.height - 40)
- ctx.setLineDash([])
-
- imageData = canvas.toDataURL('image/png', 0.95)
- console.log('使用canvas绘制方案截图成功')
- }
- else {
- throw new Error('无法创建canvas上下文')
- }
- }
- // 将base64字符串转换为File对象
- const base64ToFile = (base64String: string, filename: string): File => {
- const arr = base64String.split(',')
- const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
- const bstr = atob(arr[1])
- let n = bstr.length
- const u8arr = new Uint8Array(n)
- while (n--) {
- u8arr[n] = bstr.charCodeAt(n)
- }
- return new File([u8arr], filename, { type: mime })
- }
-
- const imageFile = base64ToFile(imageData, `screenshot_${Date.now()}.png`)
- const imageUrl = await uploadFile(imageFile)
- // 提交截图
- await submitWork(slideIndex.value, '73', imageUrl, '1') // 73表示截图工具,21表示图片类型
- message.success('页面截图提交成功')
- hasSubmitWork = true
-
- // 发送作业提交成功的socket消息
- sendMessage({
- type: 'homework_submitted',
- courseid: props.courseid,
- slideIndex: slideIndex.value,
- userid: props.userid
- })
- }
- }
- catch (error) {
- console.error('截图提交失败:', error)
- message.error('截图提交失败')
- }
- }
- }
- if (!hasSubmitWork) {
- message.info('未找到可用的作业提交功能')
- isSubmitting.value = false
- }
- }
- catch (error) {
- console.error('作业提交过程中出错:', error)
- message.error('作业提交失败')
- isSubmitting.value = false
- }
- finally {
- isSubmitting.value = false
- }
- }
- // 刷新iframe功能
- const handleRefreshPage = () => {
- console.log('刷新iframe按钮被点击')
-
- try {
- // 获取当前幻灯片中的所有iframe元素
- const iframes = document.querySelectorAll('.viewer-canvas .screen-slide')[slideIndex.value].querySelectorAll('iframe')
- console.log('找到iframe元素数量:', iframes.length)
- if (iframes.length === 0) {
- message.warning('当前页面没有找到iframe元素')
- return
- }
- let refreshedCount = 0
- // 遍历所有iframe并刷新
- for (let i = 0; i < iframes.length; i++) {
- const iframe = iframes[i] as HTMLIFrameElement
- const currentSrc = iframe.src
-
- if (currentSrc) {
- console.log(`刷新iframe ${i + 1}:`, currentSrc)
-
- // 保存当前src
- const originalSrc = currentSrc
-
- // 清空src触发刷新
- iframe.src = ''
-
- // 短暂延迟后恢复src,确保刷新生效
- setTimeout(() => {
- iframe.src = originalSrc
- console.log(`iframe ${i + 1} 刷新完成`)
- }, 100)
-
- refreshedCount++
- }
- }
- if (refreshedCount > 0) {
- message.success(`刷新完成`)
-
- // 如果当前页面有iframe,重新获取作业数据
- if (currentSlideHasIframe.value && props.type == '1') {
- setTimeout(() => {
- getWork()
- }, 500) // 延迟500ms等待iframe加载完成
- }
- }
- else {
- message.info('没有找到可刷新的iframe')
- }
- }
- catch (error) {
- console.error('刷新iframe时出错:', error)
- message.error('刷新iframe失败')
- }
- }
- // 获取作业提交按钮的右侧位置
- const getHomeworkButtonRight = () => {
- if (isFullscreen.value) {
- return 30 // 全屏时按钮在右侧30px
- }
- if (props.type === '1') {
- // 展开作业区:按钮更靠左;收起时:按钮更靠右侧
- return workPanelCollapsed.value ? 60 : 430
- }
- return 30 // type=2时按钮在右侧30px
- }
- // 获取刷新按钮的右侧位置
- const getRefreshButtonRight = () => {
- if (isFullscreen.value) {
- return 160 // 全屏时按钮在右侧150px
- }
- if (props.type === '1') {
- // 展开作业区:按钮更靠左;收起时:按钮更靠右侧
- return workPanelCollapsed.value ? 190 : 560
- }
- return 160 // type=2时按钮在右侧150px
- }
- // 键盘快捷键
- const handleKeydown = (e: KeyboardEvent) => {
- switch (e.key) {
- case 'ArrowLeft':
- e.preventDefault()
- if (!isFollowModeActive.value || props.type == '1') {
- previousSlide()
- }
- break
- case 'PageUp':
- e.preventDefault()
- if (!isFollowModeActive.value || props.type == '1') {
- previousSlide()
- }
- break
- case 'ArrowRight':
- e.preventDefault()
- if (!isFollowModeActive.value || props.type == '1') {
- nextSlide()
- }
- break
- case 'PageDown':
- case ' ':
- e.preventDefault()
- if (!isFollowModeActive.value || props.type == '1') {
- nextSlide()
- }
- break
- case 'F11':
- e.preventDefault()
- enterFullscreen()
- break
- case 'Escape':
- if (document.fullscreenElement) {
- document.exitFullscreen()
- }
- break
- default:
- break
- }
- }
- // 事件处理函数
- const handleSlidesDataUpdated = () => {
- console.log('收到 slidesDataUpdated 事件')
- // 强制重新渲染:先隐藏组件
- showSlideList.value = false
- nextTick(() => {
- calculateScale()
- // 延迟500ms后重新显示组件,确保重新渲染完成
- setTimeout(() => {
- showSlideList.value = true
- console.log('组件重新渲染完成')
- // 重新处理iframe链接
- processIframeLinks()
- }, 500)
- console.log('slidesDataUpdated 事件处理完成')
- })
- }
- const handleViewportSizeUpdated = (event: any) => {
- console.log('收到 viewportSizeUpdated 事件:', event.detail)
- // 重新计算缩放比例
- nextTick(() => {
- calculateScale()
- console.log('viewportSizeUpdated 事件处理完成')
- })
- }
- const getCourseDetail = async () => {
- isLoading.value = true
- try {
- const res = await api.getCourseDetail(props.courseid as string)
- console.log(res)
- const courseData = res[0][0]
- courseDetail.value = courseData
- selectWorksStudent()
- checkIsCreator()
- const pptJSONUrl = JSON.parse(courseData.chapters).pptData ? JSON.parse(courseData.chapters).pptData : ''
- console.log(pptJSONUrl)
-
- if (pptJSONUrl) {
- const pptdata = await getFile(pptJSONUrl)
- // pptdata.data 是 ArrayBuffer,需要先转成字符串再解析为 JSON
- let jsonStr = ''
- if (pptdata && pptdata.data) {
- // 先将 ArrayBuffer 转为字符串
- const uint8Array = new Uint8Array(pptdata.data)
- jsonStr = new TextDecoder('utf-8').decode(uint8Array)
- try {
- const jsonObj = JSON.parse(jsonStr)
- importJSON(jsonObj)
- }
- catch (e) {
- console.error('解析pptdata.data失败:', e)
- }
- }
- }
- }
- catch (error) {
- console.error('获取课程详情失败:', error)
- message.error('获取课程详情失败')
- isLoading.value = false
- }
- finally {
- isLoading.value = false
- // if (props.type == '2') {
- // console.log('判断是否是学生进入全屏')
- // function panFull() {
- // console.log('判断是否是学生进入全屏111')
- // if (!document.fullscreenElement) {
- // setTimeout(() => {
- // if (!document.fullscreenElement) {
- // if (document.documentElement.requestFullscreen) {
- // document.documentElement.requestFullscreen()
- // }
- // else if (document.documentElement.mozRequestFullScreen) { // Firefox
- // document.documentElement.mozRequestFullScreen()
- // }
- // else if (document.documentElement.webkitRequestFullscreen) { // Chrome, Safari and Opera
- // document.documentElement.webkitRequestFullscreen()
- // }
- // else if (document.documentElement.msRequestFullscreen) { // IE/Edge
- // document.documentElement.msRequestFullscreen()
- // }
- // panFull()
- // }
- // }, 50)
- // }
- // }
- // nextTick(() => {
- // setTimeout(() => {
- // // enterFullscreen();
- // panFull()
- // }, 50)
- // })
- // }
- }
- }
- const getWorkLoading = ref<any>(false)
- const getWork = async (isUpdate = false) => {
- try {
- if (getWorkLoading.value) {
- return
- }
- if (!isUpdate) {
- workLoading.value = true
- }
- getWorkLoading.value = true
- console.log('getWork 开始执行,参数:', {
- courseid: props.courseid,
- slideIndex: slideIndex.value,
- type: props.type,
- isUpdate
- })
-
- if (!props.courseid) {
- console.warn('getWork: courseid 未提供,跳过执行')
- if (!isUpdate) workLoading.value = false
- return
- }
-
- const res = await api.selectSWorks(props.courseid, '0', slideIndex.value.toString())
- console.log('getWork 执行成功,结果:', res)
- const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
- console.log('frame:', frame)
- const toolType = frame?.toolType ?? ''
- const newWorkArray = props.cid
- ? res[0].filter((work: any) => {
- return props.type == '1' || (props.type == '2' && work.classid.includes(props.cid)) && (work.atool === toolType || !toolType)
- })
- : res[0]
-
- // 如果是更新模式,只有当数据真正变化时才更新
- if (isUpdate) {
- const hasChanged = checkWorkArrayChanged(workArray.value, newWorkArray)
- if (hasChanged) {
- console.log('检测到作业数据变化,更新显示')
- workArray.value = newWorkArray
- }
- else {
- console.log('作业数据无变化,跳过更新')
- }
- }
- else {
- workArray.value = newWorkArray
- }
-
- console.log('getWork 执行成功,结果:', workArray.value)
- getWorkLoading.value = false
- }
- catch (error) {
- console.error('getWork 执行失败:', error)
- if (!isUpdate) {
- message.error('获取作业信息失败')
- }
- getWorkLoading.value = false
- }
- finally {
- if (!isUpdate) {
- workLoading.value = false
- }
- getWorkLoading.value = false
- }
- }
- const selectWorksStudent = async () => {
- studentLoading.value = true
- try {
- const res = await api.selectWorksStudent(props.oid as string, courseDetail.value.juri as string)
- console.log('selectWorksStudent', res)
- const students = res[0]
- console.log('students', students)
- if (props.cid) {
- studentArray.value = students.filter((student: any) => student.classid.includes(props.cid))
- }
- else {
- studentArray.value = students
- }
- }
- catch (error) {
- console.error('获取学生信息失败:', error)
- message.error('获取学生信息失败')
- }
- finally {
- studentLoading.value = false
- }
- }
- // 检查作业数组是否发生变化
- const checkWorkArrayChanged = (oldArray: WorkItem[], newArray: WorkItem[]): boolean => {
- if (oldArray.length !== newArray.length) return true
-
- // 检查每个作业的 id 和 name 是否一致
- for (let i = 0; i < oldArray.length; i++) {
- const oldWork = oldArray[i]
- const newWork = newArray[i]
-
- if (oldWork.id !== newWork.id || oldWork.name !== newWork.name || oldWork.content !== newWork.content) {
- return true
- }
- }
-
- return false
- }
- // 查询课程跟随状态
- const selectCourseSLook = async () => {
- const res = await api.selectCourseSLook(props.courseid as string)
- console.log('selectCourseSLook', res)
- if (res[0][0].follow == 2) {
- if (props.type == '2') {
- goToSlide(Number(res[0][0].followC))
- }
- isFollowModeActive.value = true
- }
- else {
- isFollowModeActive.value = false
- }
- }
- // 切换跟随模式
- const toggleFollowMode = async () => {
- try {
- const newFollowState = !isFollowModeActive.value
- const sopen = newFollowState ? 2 : 1
-
- // 调用API更新跟随状态
- const res = await api.updateCourseFollow(sopen, props.courseid as string)
- console.log('更新跟随模式状态:', res)
- sendMessage({sopen: newFollowState, courseid: props.courseid, type: 'sopen'})
-
- if (res) {
- isFollowModeActive.value = newFollowState
- message.success(newFollowState ? '跟随模式已开启' : '跟随模式已关闭')
-
- // 如果开启跟随模式,设置当前幻灯片为跟随目标
- if (newFollowState) {
- await api.updateCourseFollowC(slideIndex.value, props.courseid as string)
- console.log('设置当前幻灯片为跟随目标:', slideIndex.value)
- }
- }
- else {
- message.error('操作失败,请重试')
- }
- }
- catch (error) {
- console.error('切换跟随模式失败:', error)
- message.error('操作失败,请重试')
- }
- }
- // 检查是否为创建人
- const checkIsCreator = () => {
- // 这里可以根据实际业务逻辑判断是否为创建人
- // 比如通过props中的userid与课程创建者ID比较
- if (courseDetail.value && props.userid) {
- isCreator.value = courseDetail.value.userid === props.userid
- }
- }
- /**
- * 初始化消息监听
- */
- const messageInit = () => {
- if (docSocket.value && !yMessage.value) {
- console.log('获取message', docSocket.value, yMessage.value)
- yMessage.value = docSocket.value.getArray('message')
- yMessage.value.observe((e: any) => {
- e.changes.added.forEach((i: any) => {
- const message = i.content.getContent()[0]
- console.log('yMessage', message)
- const _nowTime = new Date()
- const _msgTime = new Date(message.timestamp)
- if (
- (_nowTime as any) - (_msgTime as any) <= 1000 * 10 &&
- message.mId !== mId.value
- ) {
- // 10秒内且不是自己发的消息
- getMessages(message)
- }
- })
- })
- }
- }
- /**
- * 发送消息
- */
- const sendMessage = (obj: any) => {
- if (docSocket.value && yMessage.value) {
- const message = obj
- obj.timestamp = new Date().toISOString()
- obj.mId = mId.value
- docSocket.value.transact(() => {
- yMessage.value.push([message])
- })
- }
- }
- /**
- * 处理收到的消息
- */
- const getMessages = (msgObj: any) => {
- console.log('message', msgObj)
-
- // 处理幻灯片切换消息
- if (props.type == '2' && msgObj.slideIndex && msgObj.type === 'slideIndex') {
- goToSlide(msgObj.slideIndex)
- }
-
- // 处理跟随模式状态变化
- if (props.type == '2' && msgObj.type === 'sopen') {
- selectCourseSLook()
- }
-
- // 处理作业提交消息 - 当有人提交作业时,重新获取作业数据
- if (props.type == '1' && msgObj.type === 'homework_submitted' && msgObj.courseid === props.courseid) {
- console.log('收到作业提交消息,重新获取作业数据')
- // 延迟一点时间,确保后端数据已更新
- setTimeout(() => {
- if (currentSlideHasIframe.value) {
- getWork(true) // 传入true表示是更新模式
- }
- }, 1000)
- }
- }
- onMounted(() => {
- document.addEventListener('keydown', handleKeydown)
- // 处理URL参数
- if (props.courseid || props.type) {
- console.log('收到URL参数:', { courseid: props.courseid, type: props.type })
- // 这里可以根据courseid和type进行相应的处理
- // 比如加载特定的课程数据、设置特定的显示模式等
- if (props.courseid) {
- console.log('课程ID:', props.courseid)
- // TODO: 根据courseid加载对应的课程数据
- }
- if (props.type) {
- console.log('类型:', props.type)
- // TODO: 根据type设置特定的显示模式或功能
- }
- }
- getCourseDetail()
- // 计算初始缩放比例
- nextTick(() => {
- calculateScale()
- // 处理iframe链接
- processIframeLinks()
-
- // 初始化时检查并自动切换到可用面板
- autoSwitchToAvailablePanel()
- })
- // 监听窗口大小变化
- window.addEventListener('resize', calculateScale)
- // 监听全屏状态变化
- document.addEventListener('fullscreenchange', handleFullscreenChange)
- // 监听幻灯片数据更新事件(来自useImport的readJSON)
- window.addEventListener('slidesDataUpdated', handleSlidesDataUpdated)
- // 监听视口尺寸更新事件
- window.addEventListener('viewportSizeUpdated', handleViewportSizeUpdated)
- // 将导入导出功能暴露到window上,方便调试和外部调用
- ; (window as any).PPTistStudent = {
- importJSON,
- exportJSON,
- slides: slidesStore.slides,
- currentSlide: computed(() => slidesStore.currentSlide),
- slideIndex: computed(() => slidesStore.slideIndex),
- goToSlide,
- previousSlide,
- nextSlide,
- enterFullscreen,
- toggleLaserPen,
- // 添加URL参数到全局对象中
- courseid: props.courseid,
- type: props.type
- }
- console.log('PPTist Student View 已加载,可通过 window.PPTistStudent 访问功能')
- console.log('URL参数:', { courseid: props.courseid, type: props.type })
- if (api.yweb_socket && !docSocket.value) {
-
- docSocket.value = new Y.Doc()
- providerSocket.value = new WebsocketProvider(api.yweb_socket,
- 'PPT' + props.courseid,
- docSocket.value
- )
- providerSocket.value.on('status', (event: any) => {
- console.log('👉', event.status)
- if (event.status === 'connected') {
- console.log('👉连接成功websocket(teachingMode)')
- mId.value = Math.random().toString(36).substr(2, 9)
- messageInit()
- }
- })
- }
- })
- onUnmounted(() => {
- document.removeEventListener('keydown', handleKeydown)
- window.removeEventListener('resize', calculateScale)
- document.removeEventListener('fullscreenchange', handleFullscreenChange)
- // 移除幻灯片数据更新事件监听器
- window.removeEventListener('slidesDataUpdated', handleSlidesDataUpdated)
- // 移除视口尺寸更新事件监听器
- window.removeEventListener('viewportSizeUpdated', handleViewportSizeUpdated)
- // 移除定时器清理代码,已改用socket监听
- // 清理window上的引用
- if ((window as any).PPTistStudent) {
- delete (window as any).PPTistStudent
- console.log('PPTist Student View 已卸载,window.PPTistStudent 已清理')
- }
- })
- </script>
- <style lang="scss" scoped>
- .pptist-student-viewer {
- height: 100vh;
- display: flex;
- background-color: #f5f5f5;
- // 全屏模式样式
- &.fullscreen {
- .layout-content-left {
- display: none; // 全屏时隐藏左侧导航栏
- }
- .layout-content-right {
- display: none; // 全屏时隐藏左侧导航栏
- }
- .viewer-header {
- display: none; // 全屏时隐藏顶部标题栏
- }
- }
- // 激光笔模式样式
- &.laser-pen {
- cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABHNCSVQICAgIfAhkiAAACCJJREFUWIXtmLuO3MYShv/qZl9IzqwXo2BkSAtsIK+z8wwOBcOJ9C56Cr2LlThQcgBnfofVBnswXlgTaLHaIdk3dtcJOKOzd8n2MeDABRDDgKz/m+pudv0N/BN/Luj/kYSZJQBxJR8DKESU/2zuPwTIzAKnpxqHhxUuLir0vYSUAkS0ewA5F7Rtxv7+iNPTEYeHkYjKXwrIzHK9XtultRohaKSkkFIVhqGCEAIxTvm0ZpRSTNOMUGqEUgnGxLX3cblc+t9T2S8GXK1W9dP53OLiwoLZhMtLQ4CiGBVKkchZIOcpn5QMKQuEyKx1YiCZvb0AooD9ff/rZuMPDg7cl+hWn3uAmQWABut1g/PzOnZdTd5bMY6aQtAIQQGQGEd5bYirKgPIZExiY2IKIbK1XpeinzaN2s7b4XPD/iAgM0ucn7fYbNrQ963Juaauq8k5i3E01PcG46iQs0TO1wGlzJAyo6oS2jagqgLGUQNQwTllvJeYzwUz9w8N+b2AzCxwft6i72fBuZkYhnbcbBqKsSbvazhnEIJBzqrEqGQpAlO1AaKShShC6wQpE4UQUNcBKenReyXm8yoIIYwQtNXq7qvkQxVssNm0wbmZuLiYUQgtnGtps2ngfQ3vLaVkEKOmGKcqMtMWkEnKTFonaB3Z+4AQPFmreD6vSAghxpECAFMKY7EoALovBlytVjXW6yb0fSuGoaUQWrq8nKHvW/R9S943xbmavJ+qmNIO8FMFIWXert7A1gYxjprHsSLmaTHt7UF0HYdSilmv82q1ynctnFuAzCzx8aPF+Xltcq7HzaaBcy36vsUwzKjrZhiGRgxDA+8tUjIUgkbOEqVMgEIUkjLDmAjvgwjBI6WKxlHybp5KyVRKMcaMGIb0dLFIzBxvzsdbgOv12i69t7HrpgURY02bTYO+b6nrZui6qZLONdz3jTg5ORDHx0f48OExQpgBAIzp8OjRez46Oi7Pnq1ot5BKETQVgYmosJRj6rrEQNJCxLX3EUB/LyAzC3z8qOGcIe8tOWdpmm81ed9gGJpdJdF1rXz79jucnX1za454P8fZ2ZzOzr6Rx8fvyvPnP38afiEKVVXmqhrJ+wSlIqoqYj73S2s1M7urC0ZcS3x6qhGCDpeXBuOoMY4Gzhl4b4tzNYahgXMNuq4Vb978cCfczTg7+0a8efMDuq6Fcw2GoSnO1fDewjmDcTQYx0kzBI3TU3319euAh4cVUlIEKApBU98bhGAoJSO8N/Dect834u3b73B+/vVn4XZxfv61ePv2O+77Bt5b4b2hlKbcfW8oBE2AQkoKh4fXRvU64MVFhZQqilEhBLX9CCvEqLer1YiTk4MvqtxdlTw5OcAWDDFq5DxphDBtmSlNzcddgMws0fcyDEOFUiQAiZxliVGVGFVJSXEImo6Pj3433Dbo+PiIQ9AlJbXLi5wnrVIm7b6X223wOiAAASkFhBDIWWAcJXKWshQhcpYiZ0k5S3z48PhO9ZcvgV9+ma6XL+8m/PDhMW1ziW1u5Cy3WpO2lOIq11VAAhEhRkLO0z0RgVmAefotRXz6lNyMV6+AxWK6Xr26GzCEGXZb4i7nTifnSXv6Tn7qssTdmf4+cRWQwczQmiHldM/MICogmn6FKDDmzj0Tr18D5+fT9fr13WrGdBCiXMu505Fy0mZmTJYBwPUPdUHOBaUUSFlQVRlS5rzbtqTMJGXGo0fvcXY2vyX+44/T9VA8evSepcy8zcdCFDG1ZBlSTto5FwC3P9RElNG22TTNCCEygAwps9A6Ca2TUCqRMZGPjo4fprg/+OjomIyJQqm0ywspJy0hJu22zVf34+tzcH9/hFIja51gTEJVJUiZoHWEMQFKhfLs2QpPnrz73XRPnrwrz56toFSAMQFaR0g5aRiTWOsEpUbs749XX7u51Y1QKjGQ2JjIbRtgTGClQrE2wFpPbTuU589/xmLx2xfDLRa/lefPf6a2HWCtL9YG3oJy2wY2JjKQoFTC6ekDgIeHEcZEs7cXUFURVTV1wtZ6UdcOTTOgrgfMZn158eKnL6rkkyfvyosXP2E261HXA5pmEHXtYK1HXU9WoKomTWMiDg/j1devbStEVN6/fx+XRIGt9RhHjZQ0Wat4HCsax//1fEQlf//9v8XJyTF9rt1q2+mPtW2PphnY2gHWOrbWcV17ttaDKKy9j4/398u9gACwXC49Pn7UuhQNQI3eT206s2DadptCFEiZqaoS/+tfvnz77X/oRsPKUmYyJpJSAdZ6NM2Aphl4Pu/QND3P5wO0dmo2c5jNHPb3/fKrr/xNnluARJRXq5V/2jQqOKfE1kPsPC8zM1VVLkqNwpiAEAxbq+hGy89SZtq2/MXaIOrasbUDmqZH2/Zo257bdghSOtM07tfNxh/s799yd3d6koODA8fM0ngvw9bgYG9vatOJClfVSFUVYe3UldxhmiBlxtY0kVLTlLHW8Xw+oG17NqYvs1lv6rrHcjkcEN1p5B9ydQPmc2GEoABAdB1TKYWlnDph5wJvbSdPpwvXbCcLUXhrO2FMQF0HttZBa8dtO5TZrDdt26FtewDDfRD3AhJRYeYemKxh2Bqc1HVTm17Xn4y7yFnyDeMurhh33hp3rmuvZjMXpHSmrqehXiz6h04XHjxZIKLMzB0Wi2LW64xhSAwkVFXEOGpo/dmjD2yPPlBVka31mM2caRqH5XLAnz362FUSQLdarfLTxSJpISLmcx8uLw217R8/PLpnzt3S/5KHdvG3Pn67Afr3PMB8APgvOwL+J/5s/BeEBm1u1Gu4+QAAAABJRU5ErkJggg==) 20 20, default !important;
- }
- }
- .layout-content-left {
- width: 200px;
- height: 100%;
- background-color: #fff;
- border-right: 1px solid #e0e0e0;
- overflow-y: auto;
- transition: width .2s ease;
- }
- .layout-content-left.collapsed {
- width: 48px;
- }
- .slide-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- /* 收缩时头部仅显示按钮,并保持按钮在可用宽度内水平居中 */
- .layout-content-left.collapsed .slide-header {
- justify-content: center;
- padding: 8px;
- }
- .layout-content-right {
- width: 400px;
- height: 100%;
- background-color: #fff;
- border-left: 1px solid #e0e0e0;
- overflow-y: auto;
- transition: width .2s ease;
- position: relative;
- }
- .panel-content {
- margin-right: 52px;
- padding: 0 8px;
- height: calc(100% - 76px);
- overflow: auto;
- }
- .layout-content-right.collapsed {
- width: 52px;
- }
- .right-panel-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- /* 侧边导航标签样式 */
- .side-nav-tabs {
- position: absolute;
- right: 0;
- top: 60px;
- bottom: 0;
- width: 52px;
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 8px 0;
- align-items: center;
- background: #fff;
- border-left: 1px solid #e0e0e0;
- z-index: 10;
- }
- .side-nav-btn {
- width: 45px;
- height: 45px;
- min-height: 45px;
- border: 1px solid #d9d9d9;
- background: #fff;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 8px;
- gap: 8px;
- overflow: hidden;
-
- &:hover {
- border-color: #1890ff;
- transform: scale(1.02);
- }
-
- &.active {
- border-color: #3681fc;
- background: #3681fc;
- box-shadow: 0 0 0 2px rgba(54, 129, 252, 0.2);
- }
-
- img {
- width: 25px;
- height: 25px;
- object-fit: contain;
- flex-shrink: 0;
- }
-
- span {
- font-size: 12px;
- font-weight: 500;
- color: #333;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- &.active span {
- color: #fff;
- }
- }
- /* 收缩时头部仅显示按钮,并保持按钮在可用宽度内水平居中 */
- .layout-content-right.collapsed .right-panel-header {
- justify-content: center;
- padding: 8px;
- }
- /* 收缩状态下的切换按钮 */
- .collapsed-tabs {
- display: flex;
- flex-direction: column;
- gap: 8px;
- // padding: 8px;
- align-items: center;
- }
- .collapsed-tab-btn {
- width: 32px;
- height: 32px;
- border: 1px solid #d9d9d9;
- background: #fff;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- overflow: hidden;
-
- &:hover {
- border-color: #1890ff;
- transform: scale(1.05);
- }
-
- &.active {
- border-color: #3681fc;
- background: #3681fc;
- box-shadow: 0 0 0 2px rgba(54, 129, 252, 0.2);
- }
-
- img {
- width: 20px;
- height: 20px;
- object-fit: contain;
- transition: transform 0.2s;
- }
-
- &:hover img {
- transform: scale(1.1);
- }
- }
- .collapse-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border: 1px solid #d9d9d9;
- border-radius: 8px;
- background: #fff;
- color: #333;
- cursor: pointer;
- line-height: 1;
- font-weight: 700;
- }
- .collapse-btn:hover {
- border-color: #1890ff;
- color: #1890ff;
- }
- .homework-title {
- padding: 12px 12px 0 12px;
- color: #333;
- font-size: 14px;
- font-weight: 600;
- }
- .homework-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 16px;
- padding: 12px;
- }
- .homework-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- min-width: 0;
- height: 35px;
- border: 1px solid #2f80ed;
- color: #2f80ed;
- background: #fff;
- border-radius: 8px;
- cursor: pointer;
- font-weight: 600;
- overflow: hidden;
- padding: 0 10px;
- text-align: center;
- }
- .homework-btn__text {
- display: block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .homework-btn:hover {
- box-shadow: 0 2px 10px rgba(0,0,0,0.08);
- }
- .homework-btn.unsubmitted {
- border-color: #d9d9d9;
- color: #999;
- background: #f5f5f5;
- cursor: not-allowed;
- }
- .homework-btn.unsubmitted:hover {
- box-shadow: none;
- transform: none;
- }
- .homework-loading {
- padding: 12px;
- color: #666;
- font-size: 13px;
- }
- .homework-empty {
- padding: 12px;
- color: #999;
- font-size: 13px;
- }
- .thumbnails {
- padding: 0;
- height: 100%;
- .viewer-header {
- margin-bottom: 16px;
- h3 {
- margin: 0;
- font-size: 16px;
- font-weight: 600;
- color: #333;
- }
- }
- .thumbnail-list {
- width: 100%;
- padding: 0 16px;
- box-sizing: border-box;
- .thumbnail-item {
- position: relative;
- margin-bottom: 12px;
- cursor: pointer;
- border-radius: 8px;
- overflow: hidden;
- transition: all 0.2s ease;
- border: 2px solid rgba(24, 144, 255, 0.2);
- &:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- border-color: rgba(24, 144, 255, 0.4);
- }
- &.active {
- border: 2px solid #1890ff;
- box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
- }
- .label {
- position: absolute;
- top: 8px;
- left: 8px;
- background-color: rgba(0, 0, 0, 0.6);
- color: #fff;
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
- z-index: 1;
- }
- .thumbnail {
- width: 100%;
- height: auto;
- }
- }
- }
- .page-number {
- text-align: center;
- margin-top: 16px;
- padding: 8px;
- background-color: #f0f0f0;
- border-radius: 4px;
- font-size: 14px;
- color: #666;
- }
- .progress-bar {
- margin-top: 12px;
- height: 6px;
- background-color: #f0f0f0;
- border-radius: 3px;
- overflow: hidden;
- .progress-fill {
- height: 100%;
- background: linear-gradient(90deg, #1890ff, #40a9ff);
- border-radius: 3px;
- transition: width 0.3s ease;
- }
- }
- }
- .layout-content-center {
- flex: 1;
- display: flex;
- flex-direction: column;
- background-color: #000;
- }
- .viewer-header {
- height: 60px;
- background-color: #fff;
- border-bottom: 1px solid #e0e0e0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 8px;
- transition: transform 0.3s ease;
- &.hidden {
- transform: translateY(-100%);
- }
- .slide-title {
- font-size: 18px;
- font-weight: 600;
- color: #333;
- }
- .viewer-controls {
- display: flex;
- gap: 12px;
- button {
- padding: 8px 12px;
- border: 1px solid #d9d9d9;
- border-radius: 6px;
- background-color: #fff;
- color: #333;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 40px;
- height: 36px;
- &:hover:not(:disabled) {
- border-color: #1890ff;
- color: #1890ff;
- }
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- &.back-btn {
- background-color: #1890ff;
- color: #fff;
- border-color: #1890ff;
- &:hover {
- background-color: #40a9ff;
- border-color: #40a9ff;
- }
- }
- &.follow-active {
- background-color: #3681fc;
- color: #fff !important;
- border-color: #3681fc;
- &:hover {
- background-color: #2d6fd9;
- border-color: #2d6fd9;
- color: #fff !important;
- }
- }
- .control-icon {
- font-size: 16px;
- }
- }
- }
- }
- .viewer-canvas {
- flex: 1;
- position: relative;
- background-color: rgb(249, 249, 249);
- overflow: hidden;
- // 全屏时隐藏滚动条和边框
- &.fullscreen-mode {
- overflow: hidden !important;
- background-color: transparent !important;
- }
- }
- .slide-list-wrap {
- position: absolute;
- overflow: hidden;
- background-color: #fff;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
- border-radius: 8px;
- /* 全屏时去掉圆角 */
- .pptist-student-viewer.fullscreen & {
- border-radius: 0;
- }
- }
- .loading-indicator {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
- .loading-text {
- color: #666;
- font-size: 14px;
- background-color: rgba(255, 255, 255, 0.9);
- padding: 12px 20px;
- border-radius: 6px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- }
- }
- // 全屏模式样式
- .fullscreen-slide {
- // 使用放映功能的默认样式
- }
- // 全屏工具按钮样式,直接复制放映功能的样式
- .tools-left {
- position: fixed;
- bottom: 8px;
- left: 8px;
- font-size: 25px;
- color: #666;
- z-index: 10;
- .tool-btn {
- opacity: .3;
- cursor: pointer;
- transition: opacity 0.3s;
- &:hover {
- opacity: .95;
- }
- &+.tool-btn {
- margin-left: 8px;
- }
- }
- }
- .tools-right {
- height: 66px;
- position: fixed;
- bottom: -66px;
- right: 0;
- z-index: 5;
- padding: 8px;
- transition: bottom 0.3s;
- &.visible {
- bottom: 0;
- }
- &::after {
- content: '';
- width: 100%;
- height: 66px;
- position: absolute;
- left: 0;
- top: -66px;
- }
- .content {
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- border-radius: 4px;
- font-size: 25px;
- background-color: #fff;
- color: #333;
- padding: 8px 10px;
- box-shadow: 0 2px 12px 0 rgb(56, 56, 56, .2);
- border: 1px solid #e2e6ed;
- }
- .tool-btn {
- cursor: pointer;
- &:hover,
- &.active {
- color: #1890ff;
- }
- &+.tool-btn {
- margin-left: 15px;
- }
- }
- .page-number {
- font-size: 12px;
- padding: 0 12px;
- cursor: pointer;
- }
- }
- .viewport {
- position: relative;
- width: 100%;
- height: 100%;
- background-color: #fff;
- }
- .background {
- width: 100%;
- height: 100%;
- background-position: center;
- position: absolute;
- }
- // 响应式设计
- @media (max-width: 768px) {
- .layout-content-left {
- width: 160px;
- }
- .layout-content-right {
- width: 160px;
- }
- .viewer-header {
- padding: 0 16px;
- .slide-title {
- font-size: 16px;
- }
- .viewer-controls button {
- padding: 6px 12px;
- font-size: 14px;
- }
- }
- }
- /* 作业提交按钮样式 */
- .homework-submit-btn {
- position: fixed;
- bottom: 30px;
- z-index: 100;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 12px 20px;
- border-radius: 25px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
- transition: all 0.3s ease;
- font-size: 14px;
- font-weight: 500;
- &:hover:not(.submitting) {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
- }
- &:active:not(.submitting) {
- transform: translateY(0);
- }
- &.submitting {
- cursor: not-allowed;
- opacity: 0.8;
- background: linear-gradient(135deg, #999 0%, #666 100%);
- }
- .btn-text {
- white-space: nowrap;
- }
- .tool-btn {
- background: transparent;
- color: white;
- width: 20px;
- height: 20px;
- font-size: 16px;
- &:hover {
- background: transparent;
- transform: none;
- }
- }
- .loading-spinner {
- width: 20px;
- height: 20px;
- border: 2px solid rgba(255, 255, 255, 0.3);
- border-top: 2px solid white;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- }
- }
- /* 刷新网页按钮样式 */
- .refresh-page-btn {
- position: fixed;
- bottom: 30px;
- z-index: 100;
- background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
- color: white;
- padding: 12px 20px;
- border-radius: 25px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
- transition: all 0.3s ease;
- font-size: 14px;
- font-weight: 500;
- &:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
- }
- &:active {
- transform: translateY(0);
- }
- .btn-text {
- white-space: nowrap;
- }
- .tool-btn {
- background: transparent;
- color: white;
- width: 20px;
- height: 20px;
- font-size: 16px;
- display: flex;
- align-items: center;
- &:hover {
- background: transparent;
- transform: none;
- }
- }
- }
- /* Loading状态样式 */
- .loading-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(255, 255, 255, 0.95);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- .loading-spinner {
- width: 40px;
- height: 40px;
- border: 4px solid #f3f3f3;
- border-top: 4px solid #1890ff;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin: 0 auto 16px;
- }
- }
- .loading-content {
- text-align: center;
- color: #666;
- }
- .loading-text {
- font-size: 14px;
- color: #666;
- }
- @keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
- }
- </style>
|