qgt 1 månad sedan
förälder
incheckning
931ec40ecd
1 ändrade filer med 671 tillägg och 170 borttagningar
  1. 671 170
      src/views/Student/components/answerTheResult.vue

+ 671 - 170
src/views/Student/components/answerTheResult.vue

@@ -1,208 +1,709 @@
 <template>
-  <div class="answerTheResult">
-    <div class="atr_detail">
-      <div class="atr_d_btn">查看详细</div>
-      <div class="atr_d_msg">
-        <div>参与人数</div>
-        <span>{{workArrayLength}}/{{ workArrayLength + unsubmittedStudentsLength }}</span>
+	<div class="answerTheResult">
+		<div class="atr_detail">
+			<div class="atr_d_btn">查看详细</div>
+			<div class="atr_d_msg">
+				<div>参与人数</div>
+				<span
+					>{{ workArrayLength }}/{{
+						workArrayLength + unsubmittedStudentsLength
+					}}</span
+				>
+			</div>
+
+			<div class="atr_d_msg" v-if="workDetail && workDetail.type === '45'">
+				<div>正确率</div>
+				<span>{{choiceQuestionList[workIndex].accuracyRate}}%({{choiceQuestionList[workIndex].yes}}/{{choiceQuestionList[workIndex].all}})</span>
+			</div>
+
+			<div class="atr_d_msg" v-if="choiceQuestionAnswer">
+				<div>正确答案</div>
+				<span style="color: #03ae2b">{{ choiceQuestionAnswer }}</span>
+			</div>
+
+			<span class="atr_d_line"></span>
+
+			<div class="no_submit">
+				<div>未提交人员</div>
+				<img
+					@click="showNoSubmitDetail = !showNoSubmitDetail"
+					:class="{ no_submit_active: !showNoSubmitDetail }"
+					src="../../../assets/img/arrow_up.png"
+				/>
+			</div>
+
+			<div
+				class="no_submitList"
+				v-if="props.unsubmittedStudents?.length > 0"
+				:class="{ no_submitList_active: showNoSubmitDetail }"
+			>
+				<div
+					v-for="(student, idx) in props.unsubmittedStudents"
+					:key="student.id ?? idx"
+				>
+					{{ student.name ?? "" }}
+				</div>
+			</div>
+		</div>
+
+		<div
+			class="atr_type45Area"
+			v-if="
+				workDetail &&
+				workDetail.type === '45' &&
+				choiceQuestionList[workIndex] &&
+				choiceQuestionList[workIndex].choiceUser
+			"
+		>
+
+		<div class="atr_t45a_title">
+			<span>{{ workIndex+1 }}、{{ choiceQuestionList[workIndex].type=='1'?'单选题':'多选题' }}:</span>
+			<span>{{ choiceQuestionList[workIndex].teststitle }}</span>
+		</div>
+
+			<template
+				v-for="(op, idx) in choiceQuestionList[workIndex].choiceUser"
+				:key="`${workIndex}_${idx}`"
+			>
+				<div class="atr_t45a_item">
+					<div class="atr_t45a_i_top">
+						<div class="atr_t45a_i_left">
+							<span>{{ serialNumber[idx] }}</span>
+							<div>
+								<img 
+									v-if="op.option.imgType" 
+									:src="op.option.src"
+									@click="previewImage(op.option.src)"
+								/>
+								<span v-else>{{ op.option }}</span>
+								
+							</div>
+							<span v-if="op.isAnswer">正确</span>
+						</div>
+						<img
+							@click="op.show = !op.show"
+							:class="{ show_active: !op.show }"
+							src="../../../assets/img/arrow_up.png"
+						/>
+					</div>
+					<div
+						class="atr_t45a_i_bottom"
+						v-if="op.user.length > 0"
+						:class="{ atr_t45a_i_b_active: op.show }"
+					>
+						<div
+							v-for="(name, uIdx) in op.user"
+							:key="`${workIndex}_${idx}_${uIdx}`"
+						>
+							{{ name }}
+						</div>
+					</div>
+				</div>
+			</template>
+
+			<div class="nextAndUpBtn">
+				<span :class="{no_active:workIndex==0}" @click="changeWorkIndex(0)">上一题</span>
+				<span :class="{no_active:choiceQuestionList.length-1<=workIndex}"  @click="changeWorkIndex(1)">下一题</span>
+			</div>
+		</div>
+	</div>
+
+	<!-- 预览放大(带缩放/拖拽/旋转/工具栏) -->
+  <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="atr_d_msg" v-if="workDetail && workDetail.type === '45'">
-        <div>正确率</div>
-        <span>30%(15/30)</span>
-      </div>
-
-      <div class="atr_d_msg" v-if="workDetail && workDetail.type === '45'">
-        <div>正确答案</div>
-        <span>B</span>
-      </div>
-
-      <span class="atr_d_line"></span>
-
-      <div  class="no_submit">
-        <div>未提交人员</div>
-        <img @click="showNoSubmitDetail = !showNoSubmitDetail" :class="{'no_submit_active':!showNoSubmitDetail}" src="../../../assets/img/arrow_up.png" />
-      </div>
-
-      <div class="no_submitList" :class="{'no_submitList_active':showNoSubmitDetail}">
-        <div v-for="(student, idx) in props.unsubmittedStudents" :key="student.id ?? idx">{{ student.name ?? '' }}</div>
+      <div class="image-preview__stage"
+           @mousedown="onDragStart"
+           @mousemove="onDragMove"
+           @mouseup="onDragEnd"
+           @mouseleave="onDragEnd"
+           @dblclick.stop="toggleZoom">
+        <!-- 预览中的 Loading 状态 -->
+        <div v-if="previewImageLoading" class="preview-loading">
+          <div class="loading-spinner"></div>
+          <div class="loading-text">图片加载中...</div>
+        </div>
+        
+        <img 
+          v-show="!previewImageLoading"
+          :src="imageUrl" 
+          alt="预览" 
+          class="image-preview__img"
+          :style="imgStyle" 
+          draggable="false"
+          @load="onPreviewImageLoad"
+          @error="onPreviewImageError"
+        />
       </div>
     </div>
-
-    <div class="atr_type45Area" v-if="workDetail && workDetail.type === '45'">
-      {{ workDetail.json }}
-    </div>
-  </div>
+  </Teleport>
 </template>
 
 <script lang="ts" setup>
-import { ref, computed, watch} from 'vue'
-import api from '../../../services/course'
+import { ref, computed, watch } from "vue";
+import api from "../../../services/course";
 
 interface Props {
-  workArray?: object[] | null
-  unsubmittedStudents?: object[] | null
-  workId?:string | null
+	workArray?: object[] | null;
+	unsubmittedStudents?: object[] | null;
+	workId?: string | null;
 }
 
 const props = withDefaults(defineProps<Props>(), {
-  workArray: () => [],
-  unsubmittedStudents: () => [],
-  workId: ''
-})
+	workArray: () => [],
+	unsubmittedStudents: () => [],
+	workId: "",
+});
+
 
+
+
+// 已提交的作业数量
 const workArrayLength = computed(() => {
-  let _result = 0
-  if (props.workArray) {
-    _result = props.workArray.length
+	let _result = 0;
+	if (props.workArray) {
+		_result = props.workArray.length;
+	}
+	return _result;
+});
+// 未提交的作业数量
+const unsubmittedStudentsLength = computed(() => {
+	let _result = 0;
+	if (props.unsubmittedStudents) {
+		_result = props.unsubmittedStudents.length;
+	}
+	return _result;
+});
+
+// 选项序号
+const serialNumber = ref<string[]>([
+	"A",
+	"B",
+	"C",
+	"D",
+	"E",
+	"F",
+	"G",
+	"H",
+	"I",
+	"J",
+	"K",
+	"L",
+	"M",
+	"N",
+	"O",
+	"P",
+	"Q",
+	"R",
+	"S",
+	"T",
+	"U",
+	"V",
+	"W",
+	"X",
+	"Y",
+	"Z",
+]);
+
+// 第几题
+const workIndex = ref<number>(0);
+
+// 是否显示未提交人员
+const showNoSubmitDetail = ref<boolean>(false);
+
+// 作业详细
+const workDetail = ref<any>({});
+
+// 获取作业详细
+const getWorkDetail = async () => {
+	if (props.workId) {
+		const _res = await api.getWorkDetail({ id: props.workId });
+		const _data = _res[0][0];
+		if (_data) {
+			_data.json = JSON.parse(_data.json);
+			workDetail.value = _data;
+		}
+	}
+};
+
+// 选择题题目数组
+const choiceQuestionList = computed(() => {
+	let _result: any[] = [];
+	if (workDetail.value && workDetail.value.type == "45") {
+		let _workData = workDetail.value.json.testJson;
+		_workData.forEach((item: any, index: number) => {
+			// 修复 props.workArray 可能为 null 的问题
+			item.choiceUser = [];
+			item.yes = 0;
+			item.no = 0;
+			item.checkList.forEach((op: any, oidx: number) =>
+				item.choiceUser.push({
+					option: op,
+					user: [],
+					show: false,
+					isAnswer: Array.isArray(item.answer)
+						? item.answer.includes(oidx)
+						: item.answer === oidx,
+				})
+			);
+		});
+		(props.workArray ?? []).forEach(
+			(studentWork: any, studentIndex: number) => {
+				let _studentContent: any = JSON.parse(
+					decodeURIComponent(studentWork.content)
+				).testJson;
+				_studentContent.forEach((test: any, testIndex: number) => {
+					if (test.userAnswer || test.userAnswer == 0) {
+						if (Array.isArray(test.userAnswer)) {
+							test.userAnswer.forEach((ch: number) => {
+								_workData[testIndex].choiceUser[ch].user.push(studentWork.name);
+							});
+							if (
+								JSON.stringify(
+									test.userAnswer.sort((a: number, b: number) => a - b)
+								) == JSON.stringify(_workData[testIndex].answer)
+							) {
+								_workData[testIndex].yes += 1;
+							} else {
+								_workData[testIndex].no += 1;
+							}
+						} else {
+							_workData[testIndex].choiceUser[test.userAnswer].user.push(
+								studentWork.name
+							);
+							if (test.userAnswer == _workData[testIndex].answer) {
+								_workData[testIndex].yes += 1;
+							} else {
+								_workData[testIndex].no += 1;
+							}
+						}
+					}
+				});
+			}
+		);
+
+		_workData.forEach((item:any)=>{
+			item.all = item.yes + item.no;
+			if (item.all === 0) {
+				item.accuracyRate = 0;
+			} else {
+				let rate = (item.yes / item.all) * 100;
+				item.accuracyRate = Number.isInteger(rate) ? rate : Number(rate.toFixed(2));
+			}
+		})
+
+		_result = _workData;
+	}
+
+	return _result;
+});
+
+// 选择题题目正确答案
+const choiceQuestionAnswer = computed(() => {
+	let _result = "";
+	if (
+		workDetail.value &&
+		workDetail.value.type == "45" &&
+		workDetail.value.json.testJson[workIndex.value]
+	) {
+		let _answer = workDetail.value.json.testJson[workIndex.value].answer;
+		if (Array.isArray(_answer)) {
+			_result = _answer.map((i: any) => serialNumber.value[i]).join("、");
+		} else {
+			_result = serialNumber.value[_answer];
+		}
+	}
+
+	return _result;
+});
+
+// //当前题目正确率
+// const choiceQuestionAccuracyRate = computed
+
+// 监听作业Id
+watch(
+	() => props.workId,
+	(newVal, oldVal) => {
+		console.log("props.workId变化", { newVal, oldVal });
+		if (newVal && newVal !== oldVal) {
+			getWorkDetail();
+		}
+	},
+	{ immediate: true }
+);
+
+//切换题目
+const changeWorkIndex = (type:number) =>{
+	if(type===0){
+		if(workIndex.value==0)return;
+		workIndex.value-=1;
+	}else if(type===1){
+		if(choiceQuestionList.value.length-1<=workIndex.value)return
+		workIndex.value+=1;
+	}
+}
+
+
+
+const imageUrl = ref<string>('')
+// Loading 状态
+const imageLoading = ref(false)
+const previewImageLoading = ref(false)
+
+const previewImage = (url:string)=>{
+	imageUrl.value = url;
+	previewVisible.value = true
+}
+
+// 监听图片URL变化,重置loading状态
+watch(imageUrl, (newUrl) => {
+  if (newUrl) {
+    imageLoading.value = true
+    previewImageLoading.value = true
   }
-  return _result
 })
 
-const unsubmittedStudentsLength = computed(() => {
-  let _result = 0
-  if (props.unsubmittedStudents) {
-    _result = props.unsubmittedStudents.length
+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
   }
-  return _result
 })
 
+// 图片加载事件处理
+const onImageLoad = () => {
+  imageLoading.value = false
+}
 
-console.log('workArray', props.workArray)
-console.log('unsubmittedStudents', props.unsubmittedStudents)
-
+const onImageError = () => {
+  imageLoading.value = false
+  console.error('图片加载失败')
+}
 
-const workIndex = ref<number>(0)
+const onPreviewImageLoad = () => {
+  previewImageLoading.value = false
+}
 
-const showNoSubmitDetail = ref<boolean>(false)
+const onPreviewImageError = () => {
+  previewImageLoading.value = false
+  console.error('预览图片加载失败')
+}
 
-const workDetail = ref<any>({})
+const openPreview = () => {
+  if (imageLoading.value) return // 如果主图片还在加载,不允许打开预览
+  previewVisible.value = true
+  previewImageLoading.value = true // 预览时重新显示loading
+  nextTickFit()
+}
 
-// 获取作业详细
-const getWorkDetail = async () => {
-  if (props.workId) {
-    const _res = await api.getWorkDetail({id: props.workId})
-    const _data = _res[0][0]
-    if (_data) {
-      _data.json = JSON.parse(_data.json)
-      workDetail.value = _data
-    }
-   
-  }
+const closePreview = () => {
+  previewVisible.value = false
 }
 
+const zoomStep = 0.2
+const minScale = 0.2
+const maxScale = 6
 
-// 监听作业Id
-watch(
-  () => props.workId,
-  (newVal, oldVal) => {
-    console.log('props.workId变化', { newVal, oldVal })
-    if (newVal && newVal !== oldVal) {
-      getWorkDetail()
-    }
-  },
-  { immediate: true }
-)
+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>
 .answerTheResult {
-  width: 100%;
-  height: auto;
-  max-height: 100%;
-  overflow: auto;
-  box-sizing: border-box;
-  padding: 12px;
-  .atr_detail {
-    width: 100%;
-    height: auto;
-    border-radius: 4px;
-    padding: 10px 15px 10px 15px;
-    box-sizing: border-box;
-    border: solid 1px rgba(0, 0, 0, 0.1);
-    .atr_d_btn{
-      width: 100%;
-      height: 40px;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      background: rgba(0, 0, 0, 0.9);
-      color: #fff;
-      font-weight: 500;
-      font-size: 14px;
-      border-radius: 4px;
-      cursor: pointer;
-
-    }
-
-    .atr_d_msg{
-      width: 100%;
-      display: flex;
-      align-items: center;
-      margin-top: 15px;
-      font-size: 14px;
-      color: rgba(0, 0, 0, 0.9);
-      &>div{
-        width: 4em;
-        margin-right: 35px;
-        font-weight: 500;
-      }
-    }
-
-    .atr_d_line{
-      width: 100%;
-      height: 2px;
-      display: block;
-      background: rgba(242, 243, 245, 1);
-      margin: 20px 0;
-      border-radius: 2px;
-    }
-
-    .no_submit{
-      width: 100%;
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      font-size: 14px;
-      font-weight: 500;
-      margin-bottom: 20px;
-      .no_submit_active{
-        transform: rotate(180deg);
-      }
-      &>img{
-        cursor: pointer;
-      }
-    }
-
-    .no_submitList{
-      width: 100%;
-      display: flex;
-      align-items: center;
-      flex-wrap: nowrap;
-      overflow: hidden;
-      gap: 10px;
-      &>div{
-        padding: 5px 10px;
-        background: rgba(255, 236, 232, 1);
-        color: rgba(245, 63, 63, 1);
-        font-size: 14px;
-        font-weight: 500;
-        border-radius: 10px;
-      }
-
-    }
-    .no_submitList_active{
-      flex-wrap: wrap;
-    }
-  }
-
-  .atr_type45Area{
-    width: 100%;
-    height: auto;
-  }
+	width: 100%;
+	height: auto;
+	max-height: 100%;
+	overflow: auto;
+	box-sizing: border-box;
+	padding: 12px;
+	.atr_detail {
+		width: 100%;
+		height: auto;
+		border-radius: 4px;
+		padding: 10px 15px 10px 15px;
+		box-sizing: border-box;
+		border: solid 1px rgba(0, 0, 0, 0.1);
+		.atr_d_btn {
+			width: 100%;
+			height: 40px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			background: rgba(0, 0, 0, 0.9);
+			color: #fff;
+			font-weight: 500;
+			font-size: 14px;
+			border-radius: 4px;
+			cursor: pointer;
+		}
+
+		.atr_d_msg {
+			width: 100%;
+			display: flex;
+			align-items: center;
+			margin-top: 15px;
+			font-size: 14px;
+			color: rgba(0, 0, 0, 0.9);
+			& > div {
+				width: 4em;
+				margin-right: 35px;
+				font-weight: 500;
+			}
+		}
+
+		.atr_d_line {
+			width: 100%;
+			height: 2px;
+			display: block;
+			background: rgba(242, 243, 245, 1);
+			margin: 20px 0;
+			border-radius: 2px;
+		}
+
+		.no_submit {
+			width: 100%;
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+			font-size: 14px;
+			font-weight: 500;
+			margin-bottom: 20px;
+			.no_submit_active {
+				transform: rotate(180deg);
+			}
+			& > img {
+				cursor: pointer;
+			}
+		}
+
+		.no_submitList {
+			width: 100%;
+			display: flex;
+			align-items: center;
+			flex-wrap: nowrap;
+			overflow: hidden;
+			gap: 10px;
+			& > div {
+				padding: 5px 10px;
+				background: rgba(255, 236, 232, 1);
+				color: rgba(245, 63, 63, 1);
+				font-size: 14px;
+				font-weight: 500;
+				border-radius: 10px;
+			}
+		}
+		.no_submitList_active {
+			flex-wrap: wrap;
+		}
+	}
+
+	.atr_type45Area {
+		width: 100%;
+		height: auto;
+		.atr_t45a_title{
+			width: 100%;
+			height: auto;
+			box-sizing: border-box;
+			padding: 10px 15px 10px 15px;
+			border: solid 1px #0000001a;
+			border-radius: 4px;
+			margin-top: 20px;
+			font-size: 14px;
+			font-weight: 600;
+		}
+		.atr_t45a_item {
+			width: 100%;
+			height: auto;
+			box-sizing: border-box;
+			padding: 10px 15px 10px 15px;
+			border: solid 1px #0000001a;
+			border-radius: 4px;
+			margin-top: 20px;
+			.atr_t45a_i_top {
+				width: 100%;
+				height: auto;
+				display: flex;
+				align-items: center;
+				justify-content: space-between;
+				.atr_t45a_i_left {
+					width: calc(100% - 50px);
+					height: auto;
+					display: flex;
+					align-items: center;
+					& > span:nth-child(1) {
+						display: block;
+						width: 20px;
+						height: 20px;
+						background: #fccf00;
+						border-radius: 50%;
+						padding: auto;
+						box-sizing: border-box;
+						display: flex;
+						align-items: center;
+						justify-content: center;
+						font-size: 14px;
+						font-weight: 500;
+						color: #fff;
+						margin-right: 10px;
+					}
+					& > div {
+						font-size: 14px;
+						font-weight: bold;
+						max-width: calc(100% - 20px - 10px - 45px);
+						overflow: hidden;
+						text-overflow: ellipsis;
+						white-space: nowrap;
+						display: block;
+						position: relative;
+						&>img{
+							width: 20px;
+							height: 20px;
+							object-fit: cover;
+							cursor: pointer;
+						}
+					}
+					& > span:nth-of-type(2) {
+						display: block;
+						padding: 2px 4px;
+						border-radius: 2px;
+						background: #aff0b580;
+						color: #03ae2b;
+						font-size: 12px;
+						font-weight: 400;
+						margin-left: 10px;
+					}
+				}
+				& > img {
+					width: 16px;
+					height: 16px;
+					margin-left: 20px;
+					cursor: pointer;
+				}
+				.show_active {
+					transform: rotate(180deg);
+				}
+			}
+			.atr_t45a_i_bottom {
+				width: 100%;
+				display: flex;
+				align-items: center;
+				flex-wrap: nowrap;
+				overflow: hidden;
+				gap: 10px;
+				margin-top: 10px;
+				& > div {
+					padding: 5px 10px;
+					background: #0000000f;
+					color: #222222;
+					font-size: 14px;
+					font-weight: 500;
+					border-radius: 10px;
+				}
+			}
+			.atr_t45a_i_b_active {
+				flex-wrap: wrap !important;
+			}
+		}
+		.nextAndUpBtn{
+			width: 100%;
+			height: auto;
+			display: grid;
+			grid-template-columns: 1fr 1fr;
+			margin-top: 20px;
+			gap: 15px;
+			&>span{
+				display: flex;
+				justify-content: center;
+				align-items: center;
+				width: 100%;
+				height: 40px;
+				border-radius: 5px;
+				font-size: 14px;
+				font-weight: 500;
+				color: #fff;
+				background: #3681FC;
+				cursor: pointer;
+			}
+			&>.no_active{
+				background: #cccccc !important;
+				color: #999999 !important;
+				cursor: not-allowed !important;
+				pointer-events: none !important;
+			}
+		}
+	}
 }
 </style>