Browse Source

Merge branch 'master' of https://git.cocorobo.cn/jack/PPT

lsc 1 ngày trước cách đây
mục cha
commit
b26e83c0ac

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "hfmath": "^0.0.2",
     "html-to-image": "^1.11.13",
     "html2canvas": "^1.4.1",
+    "katex": "^0.16.22",
     "lodash": "^4.17.21",
     "mitt": "^3.0.1",
     "nanoid": "^5.0.7",

+ 55 - 0
src/App.vue

@@ -129,4 +129,59 @@ window.addEventListener('beforeunload', () => {
 #app {
   height: 100%;
 }
+
+.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>

+ 381 - 6
src/views/Student/components/ChoiceWorkModal.vue

@@ -1,15 +1,101 @@
 <template>
-  <Modal :visible="visible" :width="720" :closeButton="true" @update:visible="val => emit('update:visible', val)">
-    <div>
-      <h3>选择题作业</h3>
-      <pre>{{ work }}</pre>
+  <Modal :visible="visible" :width="1024" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div class="wp_tool wp_tool45" v-if="workData">
+      <div class="wp_t45_title">题目内容</div>
+      <div
+        class="s_b_m_toolItem"
+        v-for="(item, index) in workData.testJson"
+        :key="index + '_' + workData.id"
+      >
+        <div class="s_b_m_ti_title">
+          <span>{{ index + 1 }}</span>
+          <svg
+            width="16"
+            height="16"
+            viewBox="0 0 16 16"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+          >
+            <path
+              d="M15.3536 8.35355C15.5488 8.15829 15.5488 7.84171 15.3536 7.64645L12.1716 4.46447C11.9763 4.2692 11.6597 4.2692 11.4645 4.46447C11.2692 4.65973 11.2692 4.97631 11.4645 5.17157L14.2929 8L11.4645 10.8284C11.2692 11.0237 11.2692 11.3403 11.4645 11.5355C11.6597 11.7308 11.9763 11.7308 12.1716 11.5355L15.3536 8.35355ZM1 8.5H15V7.5H1V8.5Z"
+              fill="#3681FC"
+            />
+          </svg>
+
+          <span style="display: flex;align-items: center;"
+            >{{ item.type == 1 ? "单选题:" : "多选题:"
+            }}<span v-html="renderedFormula(item.teststitle)"></span>
+          </span>
+        </div>
+        <div
+          class="s_b_m_ti_option"
+          v-for="(item2, index2) in item.checkList"
+          :key="index + '_' + index2 + 'index2T'"
+          :class="{
+            s_b_m_ti_o_choice:
+              item.type == '1'
+                ? workData.testJson[index].userAnswer === index2
+                : workData.testJson[index].userAnswer.includes(index2)
+          }"
+        >
+          <div class="s_b_m_ti_o_btn">
+            <span class="s_b_m_ti_o_btn1" v-if="item.type == 1">
+              <span
+                v-if="workData.testJson[index].userAnswer === index2"
+              ></span>
+            </span>
+            <span class="s_b_m_ti_o_btn2" v-else>
+              <span
+                v-if="workData.testJson[index].userAnswer.includes(index2)"
+              >
+              </span>
+            </span>
+          </div>
+          <span>
+            <img
+              v-if="item2.imgType && item2.imgType === 1"
+              :src="item2.src"
+              alt=""
+              @click.stop="openPreview(item2)"
+            />
+            <span v-else>{{ item2 }}</span>
+          </span>
+        </div>
+      </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 } from 'vue'
+import { computed, ref, watch } from 'vue'
 import Modal from '@/components/Modal.vue'
+import 'katex/dist/katex.min.css'
+import katex from 'katex'
 
 const props = defineProps<{
   visible: boolean
@@ -24,4 +110,293 @@ const visible = computed({
   get: () => props.visible,
   set: (v: boolean) => emit('update:visible', v)
 })
-</script> 
+
+
+
+const renderedFormula = computed(() => {
+  /**
+   * 渲染公式文本,支持带HTML标签的内容
+   * @param {string} val
+   * @returns {string}
+   */
+  const render = (val: string): string => {
+    if (typeof val !== 'string') return ''
+    const input: string = val.trim().replace(/[\u200B-\u200D\uFEFF]/g, '')
+    try {
+      // 检查是否包含HTML标签
+      const tagReg = /<([a-zA-Z][\w\-]*)([^>]*)>([\s\S]*?)<\/\1>/g
+      if (!tagReg.test(input)) {
+        // 纯文本,整体渲染
+        try {
+          return katex.renderToString(input, {
+            throwOnError: false,
+            strict: false,
+            output: 'htmlAndMathml'
+          })
+        }
+        catch {
+          return input // 渲染失败原样输出
+        }
+      }
+      else {
+        // 有标签,对每个标签内容渲染
+        return input.replace(tagReg, (match, tag, attrs, inner) => {
+          let html = inner
+          try {
+            html = katex.renderToString(inner.trim(), {
+              throwOnError: false,
+              strict: false,
+              output: 'htmlAndMathml'
+            })
+          }
+          catch {
+            // 渲染失败,保留原内容
+          }
+          return `<${tag}${attrs}>${html}</${tag}>`
+        })
+      }
+    }
+    catch (e) {
+      // console.error('KaTeX渲染错误:', e)
+      return input
+    }
+  }
+  return render
+})
+
+
+const workData = ref<any>(null)
+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 imageUrl = ref('')
+
+watch(() => props.visible, (newVal) => {
+  if (props.work && newVal) {
+    workData.value = JSON.parse(decodeURIComponent(props.work.content))
+  } 
+}, {
+  immediate: 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 = (item: any) => {
+  imageUrl.value = item.src
+  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 lang="scss" scoped>
+
+.wp_tool {
+  width: 100%;
+  height: auto;
+	max-height: 80vh;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40px 0;
+	overflow-y: auto;
+}
+
+.wp_tool45 {
+  height: auto;
+}
+
+.wp_t45_title {
+  font-size: 3em;
+  font-weight: 300;
+  margin: 20px 0 40px 0;
+}
+
+.s_b_m_toolItem {
+  width: 100%;
+  height: auto;
+  margin-bottom: 40px;
+}
+
+.s_b_m_ti_option {
+  width: 100%;
+  height: auto;
+  padding: 15px 15px 15px 15px;
+  display: flex;
+  flex-wrap: wrap;
+  background-color: #f3f7fd;
+  border-radius: 30px;
+  margin: 10px 0 10px 0px;
+  box-sizing: border-box;
+}
+
+.s_b_m_ti_option > span > img {
+  max-height: 150px;
+  border-radius: 2px;
+  cursor: pointer;
+}
+
+.s_b_m_ti_o_btn {
+  width: 20px;
+  height: 100%;
+  min-height: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 10px;
+}
+
+.s_b_m_ti_o_btn > span {
+  width: 20px;
+  height: 20px;
+  display: block;
+  box-sizing: border-box;
+  border: solid 1px #3681fc;
+  overflow: hidden;
+}
+
+.s_b_m_ti_o_btn > span > span {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #3681fc;
+  position: relative;
+}
+
+.s_b_m_ti_o_btn1 {
+  border-radius: 50%;
+}
+
+.s_b_m_ti_o_btn1 > span::after {
+  content: "";
+  width: 8px;
+  height: 8px;
+  position: absolute;
+  background-color: #fff;
+  border-radius: 50%;
+}
+
+.s_b_m_ti_o_btn2 {
+  border-radius: 2px;
+}
+
+.s_b_m_ti_o_btn2 > span::after {
+  content: "";
+  width: 8px;
+  height: 8px;
+  position: absolute;
+  background-color: #fff;
+}
+
+.s_b_m_ti_o_choice {
+  border: solid 1px #3681fc;
+}
+
+.s_b_m_ti_title {
+  display: flex;
+  align-items: center;
+}
+
+.s_b_m_ti_title > span:nth-of-type(1) {
+  font-size: 30px;
+  font-weight: bold;
+  color: #3681fc;
+}
+
+.s_b_m_ti_title > svg {
+  width: 30px;
+  height: 30px;
+  margin: 0 20px 0 5px;
+}
+
+.s_b_m_ti_title > span:nth-of-type(2) {
+  font-size: 20px;
+  font-weight: bold;
+}
+
+.s_b_m_ti_title > div {
+  font-size: 30px;
+  font-weight: bold;
+}
+</style>

+ 254 - 6
src/views/Student/components/QAWorkModal.vue

@@ -1,14 +1,52 @@
 <template>
-  <Modal :visible="visible" :width="720" :closeButton="true" @update:visible="val => emit('update:visible', val)">
-    <div>
-      <h3>问答作业</h3>
-      <pre>{{ work }}</pre>
+  <Modal :visible="visible" :width="1024" :closeButton="true" @update:visible="val => emit('update:visible', val)">
+    <div class="wp_tool wp_tool15" v-if="workData">
+      <div class="wp_t15_title">{{ workData.answerQ }}</div>
+      <span class="wp_type">问答题</span>
+      <div class="wp_tl15_inputArea">
+       <div class="wp_tl15_inputArea_content">
+				<span v-text="workData.answer"></span>
+       </div>
+      </div>
+      <div class="wp_tl15_fileList">
+        <div
+          class="wp_tl15_fileList_item"
+          v-for="(item, index) in workData.fileList"
+          :key="index"
+        >
+          <img :src="item.url" alt="" @click.stop="openPreview(item)"/>
+        </div>
+      </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 } from 'vue'
+import { computed, watch, ref } from 'vue'
 import Modal from '@/components/Modal.vue'
 
 const props = defineProps<{
@@ -16,6 +54,35 @@ const props = defineProps<{
   work: any
 }>()
 
+const workData = ref<any>(null)
+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 imageUrl = ref('')
+
+watch(() => props.visible, (newVal) => {
+  if (props.work && newVal) {
+    workData.value = JSON.parse(decodeURIComponent(props.work.content))
+  } 
+}, {
+  immediate: true,
+})
+
+
+
+const imgStyle = computed(() => {
+  const t = `translate(${offsetX.value}px, ${offsetY.value}px) rotate(${rotate.value}deg) scale(${scale.value})`
+  return {
+    transform: t
+  }
+})
+
 const emit = defineEmits<{
   (e: 'update:visible', v: boolean): void
 }>()
@@ -24,4 +91,185 @@ const visible = computed({
   get: () => props.visible,
   set: (v: boolean) => emit('update:visible', v)
 })
-</script> 
+
+const openPreview = (item: any) => {
+  imageUrl.value = item.url
+  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>
+.wp_tool {
+  width: 100%;
+  height: auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40px 0;
+}
+
+.wp_t15_title {
+  font-size: 3em;
+  font-weight: 300;
+  margin: 20px 0;
+}
+
+.wp_type {
+  margin: 20px 0;
+  font-size: 1em;
+  color: #7b7b7b;
+}
+
+.wp_tl15_inputArea {
+  width: 70%;
+  min-width: 300px;
+  margin: 20px 0;
+}
+
+.wp_tl_btn {
+  width: auto;
+  height: auto;
+  padding: 12px 16px;
+  background-color: #f9f9f9;
+  border-radius: 10px;
+  border: 1px dashed #e5e5e5;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #474747;
+  cursor: pointer;
+  margin-right: 10px;
+  transition: 0.3s;
+}
+
+.wp_tl_btn:hover {
+  background-color: #ececec;
+}
+
+.wp_tl_btn > svg {
+  width: 15px;
+  height: 15px;
+  margin-right: 5px;
+}
+
+.wp_tl15_fileList {
+  width: 70%;
+  min-width: 300px;
+  height: auto;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.wp_tl15_fileList_item {
+  width: 150px;
+  height: 100px;
+  border-radius: 10px;
+  position: relative;
+  margin-right: 20px;
+}
+
+.wp_tl15_fileList_item:hover > svg {
+  display: block;
+}
+
+.wp_tl15_fileList_item > img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  border-radius: 10px;
+  box-shadow: 0 0 10px #ececec;
+  margin: 10px;
+  cursor: pointer;
+}
+
+.wp_tl15_fileList_item > svg {
+  width: 20px;
+  height: 20px;
+  position: absolute;
+  right: -20px;
+  top: 1px;
+  cursor: pointer;
+  display: none;
+}
+
+.wp_tl15_inputArea_content{
+	border-color: #ececec;
+  border-radius: 10px;
+  padding: 10px 20px;
+  box-shadow: 0 0 10px #ececec;
+	width: 100%;
+	height: 150px;
+	overflow: auto;
+}
+
+</style>

+ 4 - 4
src/views/Student/index.vue

@@ -281,10 +281,10 @@ watch(() => workPanelCollapsed.value, async () => {
 const openWorkModal = (work: WorkItem) => {
   selectedWork.value = work
   const t = Number(work?.type)
-  if (t !== 1) {
-    message.warning('暂未开发完成')
-    return
-  }
+  // if (t !== 1) {
+  //   message.warning('暂未开发完成')
+  //   return
+  // }
   visibleShot.value = false
   visibleQA.value = false
   visibleChoice.value = false

+ 1 - 0
src/views/components/ThumbnailSlide/index.vue

@@ -76,6 +76,7 @@ provide(injectKeySlideScale, scale);
   background-color: #fff;
   overflow: hidden;
   user-select: none;
+  position: relative;
 }
 .elements {
   transform-origin: 0 0;