lsc hai 11 horas
pai
achega
60768d24a8

+ 27 - 0
src/views/Student/components/AIWorkModal.vue

@@ -0,0 +1,27 @@
+<template>
+  <Modal :visible="visible" :width="720" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div>
+      <h3>AI 应用作业</h3>
+      <pre>{{ work }}</pre>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import Modal from '@/components/Modal.vue'
+
+const props = defineProps<{
+  visible: boolean
+  work: any
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', v: boolean): void
+}>()
+
+const visible = computed({
+  get: () => props.visible,
+  set: (v: boolean) => emit('update:visible', v)
+})
+</script> 

+ 27 - 0
src/views/Student/components/ChoiceWorkModal.vue

@@ -0,0 +1,27 @@
+<template>
+  <Modal :visible="visible" :width="720" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div>
+      <h3>选择题作业</h3>
+      <pre>{{ work }}</pre>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import Modal from '@/components/Modal.vue'
+
+const props = defineProps<{
+  visible: boolean
+  work: any
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', v: boolean): void
+}>()
+
+const visible = computed({
+  get: () => props.visible,
+  set: (v: boolean) => emit('update:visible', v)
+})
+</script> 

+ 27 - 0
src/views/Student/components/QAWorkModal.vue

@@ -0,0 +1,27 @@
+<template>
+  <Modal :visible="visible" :width="720" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div>
+      <h3>问答作业</h3>
+      <pre>{{ work }}</pre>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import Modal from '@/components/Modal.vue'
+
+const props = defineProps<{
+  visible: boolean
+  work: any
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', v: boolean): void
+}>()
+
+const visible = computed({
+  get: () => props.visible,
+  set: (v: boolean) => emit('update:visible', v)
+})
+</script> 

+ 223 - 0
src/views/Student/components/ShotWorkModal.vue

@@ -0,0 +1,223 @@
+<template>
+  <Modal :visible="visible" :width="800" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div class="shot-wrap">
+      <img v-if="imageUrl" :src="imageUrl" alt="截图作业" class="shot-img" @click="openPreview" />
+      <div v-else class="shot-empty">暂无图片</div>
+    </div>
+  </Modal>
+
+  <!-- 预览放大(带缩放/拖拽/旋转/工具栏) -->
+  <Teleport to="body">
+    <div v-if="previewVisible" class="image-preview" @click.self="closePreview" @wheel.prevent="onWheel">
+      <div class="image-preview__toolbar">
+        <button @click.stop="zoomOut">-</button>
+        <button @click.stop="zoomIn">+</button>
+        <button @click.stop="resetTransform">重置</button>
+        <button @click.stop="rotateLeft">⟲</button>
+        <button @click.stop="rotateRight">⟳</button>
+        <button @click.stop="toggleFit">{{ fitMode ? '实际大小' : '适应屏幕' }}</button>
+        <button @click.stop="closePreview">关闭</button>
+      </div>
+      <div class="image-preview__stage"
+           @mousedown="onDragStart"
+           @mousemove="onDragMove"
+           @mouseup="onDragEnd"
+           @mouseleave="onDragEnd"
+           @dblclick.stop="toggleZoom">
+        <img :src="imageUrl" alt="预览" class="image-preview__img"
+             :style="imgStyle" draggable="false" />
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import Modal from '@/components/Modal.vue'
+
+const props = defineProps<{
+  visible: boolean
+  work: any
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', v: boolean): void
+}>()
+
+const visible = computed({
+  get: () => props.visible,
+  set: (v: boolean) => emit('update:visible', v)
+})
+
+const imageUrl = computed(() => {
+  const raw = props.work?.content
+  if (!raw) return ''
+  try {
+    const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
+    return obj?.url || obj?.image || obj?.src || ''
+  }
+  catch {
+    return String(raw)
+  }
+})
+
+const previewVisible = ref(false)
+const scale = ref(1)
+const rotate = ref(0)
+const offsetX = ref(0)
+const offsetY = ref(0)
+const dragging = ref(false)
+const lastX = ref(0)
+const lastY = ref(0)
+const fitMode = ref(true)
+
+const imgStyle = computed(() => {
+  const t = `translate(${offsetX.value}px, ${offsetY.value}px) rotate(${rotate.value}deg) scale(${scale.value})`
+  return {
+    transform: t
+  }
+})
+
+const openPreview = () => {
+  previewVisible.value = true
+  nextTickFit()
+}
+const closePreview = () => {
+  previewVisible.value = false
+}
+
+const zoomStep = 0.2
+const minScale = 0.2
+const maxScale = 6
+
+const zoomIn = () => {
+  fitMode.value = false; scale.value = Math.min(maxScale, +(scale.value + zoomStep).toFixed(2)) 
+}
+const zoomOut = () => {
+  fitMode.value = false; scale.value = Math.max(minScale, +(scale.value - zoomStep).toFixed(2)) 
+}
+const resetTransform = () => {
+  scale.value = 1; rotate.value = 0; offsetX.value = 0; offsetY.value = 0; fitMode.value = true; nextTickFit() 
+}
+const rotateLeft = () => {
+  rotate.value = (rotate.value - 90) % 360 
+}
+const rotateRight = () => {
+  rotate.value = (rotate.value + 90) % 360 
+}
+
+const toggleZoom = () => {
+  fitMode.value = false
+  scale.value = scale.value >= 1.8 ? 1 : 2
+}
+
+const toggleFit = () => {
+  fitMode.value = !fitMode.value
+  nextTickFit()
+}
+
+const nextTickFit = () => {
+  // 适应屏幕时复位位置与缩放
+  if (fitMode.value) {
+    scale.value = 1
+    offsetX.value = 0
+    offsetY.value = 0
+  }
+}
+
+const onWheel = (e: WheelEvent) => {
+  if (e.deltaY > 0) zoomOut()
+  else zoomIn()
+}
+
+const onDragStart = (e: MouseEvent) => {
+  dragging.value = true
+  lastX.value = e.clientX
+  lastY.value = e.clientY
+}
+const onDragMove = (e: MouseEvent) => {
+  if (!dragging.value) return
+  const dx = e.clientX - lastX.value
+  const dy = e.clientY - lastY.value
+  lastX.value = e.clientX
+  lastY.value = e.clientY
+  offsetX.value += dx
+  offsetY.value += dy
+}
+const onDragEnd = () => {
+  dragging.value = false 
+}
+</script>
+
+<style scoped>
+.shot-wrap {
+  max-height: 70vh;
+  overflow: auto;
+}
+.shot-img {
+  display: block;
+  max-width: 100%;
+  height: auto;
+  border-radius: 6px;
+  cursor: zoom-in;
+}
+.shot-empty {
+  color: #999;
+  font-size: 14px;
+}
+
+.image-preview {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.85);
+  display: flex;
+  flex-direction: column;
+  z-index: 6000;
+}
+.image-preview__toolbar {
+  display: flex;
+  gap: 8px;
+  padding: 10px;
+  justify-content: center;
+  z-index: 9999;
+}
+.image-preview__toolbar button {
+  padding: 8px 12px;
+  border: none;
+  background: linear-gradient(180deg, #3a8bff 0%, #2f80ed 100%);
+  color: #fff;
+  border-radius: 10px;
+  cursor: pointer;
+  transition: transform .15s ease, box-shadow .2s ease, background .2s ease;
+  box-shadow: 0 2px 8px rgba(47,128,237,.3);
+}
+.image-preview__toolbar button:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 6px 16px rgba(47,128,237,.35);
+}
+.image-preview__toolbar button:active {
+  transform: translateY(0);
+  box-shadow: 0 2px 8px rgba(47,128,237,.28);
+  background: linear-gradient(180deg, #2f80ed 0%, #1b6dde 100%);
+}
+.image-preview__toolbar button:focus-visible {
+  outline: 2px solid rgba(47,128,237,.6);
+  outline-offset: 2px;
+}
+.image-preview__stage {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: grab;
+}
+.image-preview__stage:active { cursor: grabbing; }
+.image-preview__img {
+  max-width: 92vw;
+  max-height: 92vh;
+  border-radius: 8px;
+  box-shadow: 0 10px 30px rgba(0,0,0,.3);
+  user-select: none;
+  will-change: transform;
+}
+</style> 

+ 188 - 8
src/views/Student/index.vue

@@ -114,14 +114,37 @@
         </div>
       </div>
     </div>
-    <div class="layout-content-right" v-show="type == '1'">
+    <div class="layout-content-right" v-show="type == '1'" :class="{ collapsed: workPanelCollapsed }">
       <div class="thumbnails">
-        <div class="viewer-header">
-          <h3>作业区</h3>
+        <div class="viewer-header homework-header">
+          <h3 v-show="!workPanelCollapsed">作业区</h3>
+          <button class="collapse-btn" @click="workPanelCollapsed = !workPanelCollapsed" :title="workPanelCollapsed ? '展开' : '收起'">
+            <span v-if="workPanelCollapsed">›</span>
+            <span v-else>‹</span>
+          </button>
+        </div>
+        <div v-show="!workPanelCollapsed">
+          <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>
       </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>
@@ -140,6 +163,10 @@ 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'
 
 // 定义组件props
 interface Props {
@@ -191,9 +218,68 @@ const slideHeight = ref(0)
 
 // 添加loading状态
 const isLoading = ref(false)
+const workLoading = ref(false)
 
 // 作业数组
-const workArray = ref([])
+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)
+
+// 收缩/展开后重新计算中间画布尺寸(在 DOM 更新并完成过渡后)
+watch(() => workPanelCollapsed.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 = () => {
@@ -317,7 +403,7 @@ const nextSlide = () => {
 // 监听slideIndex变化,调用getWork
 watch(() => slideIndex.value, (newIndex, oldIndex) => {
   console.log('slideIndex变化,调用getWork', { newIndex, oldIndex })
-  if (newIndex !== oldIndex && typeof newIndex === 'number') {
+  if (newIndex !== oldIndex && typeof newIndex === 'number' && currentSlideHasIframe.value) {
     console.log('触发getWork,当前幻灯片索引:', newIndex)
     getWork()
   }
@@ -590,6 +676,7 @@ const submitWork = async (slideIndex: number, atool: string, content: string, ty
     content: content,
     type: type
   })
+  getWork()
   console.log(res)
 }
 // 文件上传到AWS S3的函数
@@ -817,11 +904,13 @@ const handleHomeworkSubmit = async () => {
 
     if (!hasSubmitWork) {
       message.info('未找到可用的作业提交功能')
+      isSubmitting.value = false
     }
   }
   catch (error) {
     console.error('作业提交过程中出错:', error)
     message.error('作业提交失败')
+    isSubmitting.value = false
   }
   finally {
     isSubmitting.value = false
@@ -834,7 +923,8 @@ const getHomeworkButtonRight = () => {
     return 30 // 全屏时按钮在右侧30px
   }
   if (props.type === '1') {
-    return 230 // type=1时(有左侧导航栏)按钮在右侧230px
+    // 展开作业区:按钮更靠左;收起时:按钮更靠右侧
+    return workPanelCollapsed.value ? 60 : 430
   }
   return 30 // type=2时按钮在右侧30px
 }
@@ -940,6 +1030,7 @@ const getCourseDetail = async () => {
 
 const getWork = async () => {
   try {
+    workLoading.value = true
     console.log('getWork 开始执行,参数:', {
       courseid: props.courseid,
       slideIndex: slideIndex.value,
@@ -948,14 +1039,18 @@ const getWork = async () => {
     
     if (!props.courseid) {
       console.warn('getWork: courseid 未提供,跳过执行')
+      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 ?? ''
     workArray.value = props.cid
       ? res[0].filter((work: any) => {
-        return work.type === 1 || (work.type === 2 && work.classid.includes(props.cid))
+        return work.type === 1 || (work.type === 2 && work.classid.includes(props.cid)) && (work.atool === toolType || !toolType)
       })
       : res[0]
     console.log('getWork 执行成功,结果:', workArray.value)
@@ -964,6 +1059,9 @@ const getWork = async () => {
     console.error('getWork 执行失败:', error)
     message.error('获取作业信息失败')
   }
+  finally {
+    workLoading.value = false
+  }
 }
 
 onMounted(() => {
@@ -1085,11 +1183,93 @@ onUnmounted(() => {
 }
 
 .layout-content-right {
-  width: 200px;
+  width: 400px;
   height: 100%;
   background-color: #fff;
   border-left: 1px solid #e0e0e0;
   overflow-y: auto;
+  transition: width .2s ease;
+}
+.layout-content-right.collapsed {
+  width: 48px;
+}
+.homework-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+/* 收缩时头部仅显示按钮,并保持按钮在可用宽度内水平居中 */
+.layout-content-right.collapsed .homework-header {
+  justify-content: center;
+  padding: 8px;
+}
+.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-loading {
+  padding: 12px;
+  color: #666;
+  font-size: 13px;
+}
+.homework-empty {
+  padding: 12px;
+  color: #999;
+  font-size: 13px;
 }
 
 .thumbnails {