Sfoglia il codice sorgente

修改:学生端教辅作业测试bug

吴朋磊 2 settimane fa
parent
commit
7165661c60

+ 8 - 0
src/http/api/teachingHomework.js

@@ -4,6 +4,14 @@ import base from '../common/base';
 
 const teachingHomework = {
   // 作业任务列表
+  // 头部查询条件
+  // params: { pageNum, pageSize, courseCode, status, queryStr }
+  // status: 0未提交 1已提交
+  queryStudentAidSubject(data) {
+    return get(base.prefix + `/api/v1/student_class_home_work/query_student_aid_subject`, data);
+  },
+
+
   // params: { pageNum, pageSize, courseCode, status, queryStr }
   // status: 0未提交 1已提交
   queryClassHomeWorkByPage(data) {

+ 3 - 0
src/main.js

@@ -39,7 +39,10 @@ Vue.component("Footer", Footer);
 import InfiniteLoading from "vue-infinite-loading";
 import lottie from "vue-lottie";
 import global from "@/utils/global.js";
+import envFeature from "@/utils/envFeature.js";
 Vue.prototype.$global = global;
+// 环境显隐:组件内无需 import,直接用 this.$feature.isShow() / isHide()
+Vue.prototype.$feature = envFeature;
 
 Vue.use(InfiniteLoading);
 Vue.component("lottie", lottie);

+ 31 - 0
src/utils/envFeature.js

@@ -0,0 +1,31 @@
+/**
+ * 环境显隐控制
+ *
+ * 规则:环境变量 VUE_APP_BASE_API 中包含 https://www.k12100.com/ 即视为生产环境,
+ * 未完成的功能 / 模块需要隐藏;不包含(测试、开发环境)则正常显示。
+ *
+ * 已在 main.js 全局挂载,组件中直接使用,无需再 import:
+ *   模板:v-if="$feature.isShow()"
+ *   js:  this.$feature.isShow()
+ */
+
+// 生产环境地址标识
+const PROD_FLAG = 'https://www.k12100.com/';
+
+// 当前是否生产环境
+function isProd() {
+  return String(process.env.VUE_APP_BASE_API || '').indexOf(PROD_FLAG) !== -1;
+}
+
+export default {
+  // 是否生产环境
+  isProd,
+  // 生产环境隐藏(true=隐藏)
+  isHide() {
+    return isProd();
+  },
+  // 非生产环境显示(true=显示)
+  isShow() {
+    return !isProd();
+  },
+};

+ 9 - 6
src/views/layout/components/Header.vue

@@ -202,6 +202,11 @@ export default {
 
     //学生端默认菜单
     const baseMenuList = [
+      {
+        menuName: "教辅作业",
+        externalLink: "/teachingHomework",
+        feature: "teachingHomework", // 生产环境隐藏的未完成模块
+      },
       {
         menuName: "分析报告",
         externalLink: "/jointStudentAnalysisReport/list",
@@ -210,17 +215,15 @@ export default {
         menuName: "靶向提升",
         externalLink: "/targetImprove",
       },
-      {
-        menuName: "教辅作业",
-        externalLink: "/teachingHomework",
-      },
+      
       {
         menuName: "个人中心",
         externalLink: "/userInfo",
       },
     ];
-  
-    this.menuList = baseMenuList;
+
+    // 带 feature 标识的菜单(未完成模块),生产环境隐藏,测试/开发环境显示
+    this.menuList = baseMenuList.filter(item => !item.feature || this.$feature.isShow());
     
   },
   methods: {

+ 472 - 64
src/views/teachingHomework/homeworkTask/analysis.vue

@@ -156,21 +156,76 @@
                 </span>
               </div>
 
-              <!-- 我的作答:writeLine 留痕图片 -->
+              <!-- 我的作答:imgUrlList 有值为非选择题展示作答留痕图片;无值为选择题只读回显选项 -->
               <div v-if="detailTab === 'answer'" class="detail_content answer_content">
-                <template v-if="selectedQuestion.answerImages.length">
-                  <el-image
-                    v-for="(url, idx) in selectedQuestion.answerImages"
-                    :key="idx"
-                    class="answer_img"
-                    :src="url"
-                    fit="contain"
-                    :preview-src-list="selectedQuestion.answerImages"
-                    :z-index="3000">
-                  </el-image>
-                </template>
-                <div v-else class="content_empty no_content_data">
-                  <span>暂无作答内容</span>
+                <!-- 非选择题:容器内嵌图片查看器,居中展示并支持缩放、拖动、多图翻阅 -->
+                <div v-if="answerImgList.length" class="img_viewer">
+                  <div
+                    class="viewer_stage"
+                    ref="viewerStage"
+                    :class="{ dragging: answerImgDragging }"
+                    @wheel.prevent="onAnswerImgWheel"
+                    @pointerdown="startAnswerImgDrag"
+                    @dblclick="onStageDblClick">
+                    <img
+                      class="viewer_img"
+                      :src="currentAnswerImg"
+                      :alt="`作答图片${answerImgIndex + 1}`"
+                      :style="{ transform: `translate(${answerImgX}px, ${answerImgY}px) scale(${answerImgScale})` }"
+                      draggable="false">
+                    <button
+                      v-if="answerImgList.length > 1"
+                      type="button"
+                      class="viewer_arrow left"
+                      @click="prevAnswerImg">
+                      <i class="el-icon-arrow-left"></i>
+                    </button>
+                    <button
+                      v-if="answerImgList.length > 1"
+                      type="button" 
+                      class="viewer_arrow right"
+                      @click="nextAnswerImg">
+                      <i class="el-icon-arrow-right"></i>
+                    </button>
+                    <div class="viewer_zoom">
+                      <button type="button" class="zoom_btn" @click="zoomAnswerImg(-0.2)">
+                        <i class="el-icon-minus"></i>
+                      </button>
+                      <span class="zoom_value" title="点击恢复100%并居中" @click="resetAnswerImgView">
+                        {{ Math.round(answerImgScale * 100) }}%
+                      </span>
+                      <button type="button" class="zoom_btn" @click="zoomAnswerImg(0.2)">
+                        <i class="el-icon-plus"></i>
+                      </button>
+                    </div>
+                    <!-- 多图页码:悬浮于舞台底部居中,不占布局空间;容器不拦截指针,仅数字可点 -->
+                    <div v-if="answerImgList.length > 1" class="viewer_index">
+                      <span
+                        v-for="(url, idx) in answerImgList"
+                        :key="idx"
+                        class="index_num"
+                        :class="{ active: idx === answerImgIndex }"
+                        @click="goAnswerImg(idx)">
+                        {{ idx + 1 }}
+                      </span>
+                    </div>
+                  </div>
+                </div>
+
+                <!-- 无作答图片即为选择题:choiceOptions 决定选项,stuAnswer 决定选中项,只读不可更改 -->
+                <div v-else class="choice_view">
+                  <span class="choice_label">我的作答:</span>
+                  <el-checkbox-group
+                    v-model="selectedQuestion.stuAnswerKeys"
+                    disabled
+                    class="choice_radio">
+                    <el-checkbox
+                      v-for="key in selectedQuestion.choiceOptions"
+                      :key="key"
+                      :label="key">
+                      {{ key }}
+                    </el-checkbox>
+                  </el-checkbox-group>
                 </div>
               </div>
 
@@ -214,7 +269,7 @@
           <div class="page_table analysis_table_wrap">
             <el-table
               :data="questionPage"
-              height="572"
+              height="575"
               stripe
               class="el-table--border analysis_table">
               <el-table-column prop="name" label="小题名称" min-width="120" show-overflow-tooltip />
@@ -287,7 +342,7 @@
           <div class="page_table analysis_table_wrap">
             <el-table
               :data="knowledgePage"
-              height="572"
+              height="575"
               stripe
               class="el-table--border analysis_table">
               <el-table-column prop="name" label="知识点" min-width="200" show-overflow-tooltip />
@@ -377,37 +432,50 @@ function mapKnowledgeList(list, fallback) {
   return fallback ? [String(fallback)] : [];
 }
 
-// writeLine 为作答留痕数据(JSON),兼容字符串/数组/对象多种形态,提取作答图片地址
-function parseWriteLine(raw) {
-  if (!raw) return [];
-  let data = raw;
-  if (typeof raw === 'string') {
-    const text = raw.trim();
-    if (!text) return [];
-    try {
-      data = JSON.parse(text);
-    } catch (e) {
-      // 非 JSON 的纯字符串视为单个图片地址
-      return /^https?:\/\//.test(text) ? [text] : [];
+// 作答留痕图片:imgUrlList 为图片地址数组,兼容字符串/对象元素及逗号串多种形态
+function normalizeImgList(list) {
+  if (Array.isArray(list)) {
+    return list.map(item => {
+      if (typeof item === 'string') return item;
+      return (item && (item.url || item.imgUrl || item.imageUrl || item.path || item.filePath)) || '';
+    }).filter(Boolean);
+  }
+  if (typeof list === 'string' && list.trim()) {
+    return list.split(',').map(url => url.trim()).filter(Boolean);
+  }
+  return [];
+}
+
+// 选择题选项 key:优先用 optionsNum 生成 A/B/C/D…;缺失时兜底用题干 optionList 自带的 key
+function buildChoiceOptions(optionsNum, optionList) {
+  const count = toNumber(optionsNum);
+  if (count) {
+    const options = [];
+    for (let i = 0; i < count && i < OPTION_KEYS.length; i += 1) {
+      options.push(OPTION_KEYS.charAt(i));
     }
+    return options;
   }
-  const pick = obj => {
-    if (!obj || typeof obj !== 'object') return [];
-    const urls = obj.writeUrls || obj.urls || obj.imgUrls || obj.images || obj.imgs;
-    if (Array.isArray(urls)) return urls;
-    return obj.url || obj.imageUrl ? [obj.url || obj.imageUrl] : [];
-  };
-  if (Array.isArray(data)) {
-    return data.reduce((acc, item) => {
-      if (typeof item === 'string') {
-        if (/^https?:\/\//.test(item)) acc.push(item);
-      } else {
-        pick(item).forEach(url => acc.push(url));
-      }
-      return acc;
-    }, []).filter(Boolean);
+  if (Array.isArray(optionList) && optionList.length) {
+    return optionList.map(opt => (opt && opt.key) || '').filter(key => /^[A-Z]$/.test(key));
+  }
+  return [];
+}
+
+// stuAnswer 回显:兼容 "A,D"、"AD"、["A"] 等格式,只保留合法选项并去重
+function parseStuAnswerKeys(value, validKeys) {
+  let keys = [];
+  if (Array.isArray(value)) {
+    keys = value;
+  } else if (value !== null && value !== undefined && String(value).trim() !== '') {
+    keys = String(value).toUpperCase().match(/[A-Z]/g) || [];
   }
-  return pick(data).filter(Boolean);
+  const result = [];
+  keys.forEach(key => {
+    const k = String(key);
+    if (validKeys.indexOf(k) !== -1 && result.indexOf(k) === -1) result.push(k);
+  });
+  return result;
 }
 
 // 子题(subQuestions)映射
@@ -426,6 +494,9 @@ function mapSubQuestion(item, index) {
 
 // 学生题目列表(studentQuestionVOList)映射为小题行
 function mapQuestionRow(item, index) {
+  const options = mapOptionList(item.optionList);
+  // imgUrlList 有值为非选择题;无值为选择题,选项优先取 optionsNum,缺失时兜底题干选项 key
+  const choiceOptions = buildChoiceOptions(item.optionsNum, options);
   return {
     id: item.questionId || item.id || index,
     name: item.questionName || '-',
@@ -435,8 +506,12 @@ function mapQuestionRow(item, index) {
     title: item.title || '',
     answer: item.answer || '',
     parse: item.parse || '',
-    options: mapOptionList(item.optionList),
-    answerImages: parseWriteLine(item.writeLine),
+    options,
+    // 选择题只读回显:选项 key 列表 + stuAnswer 选中项
+    choiceOptions,
+    stuAnswerKeys: parseStuAnswerKeys(item.stuAnswer, choiceOptions),
+    // 非选择题作答留痕图片;是否选择题以该数组是否有值为准
+    imgUrlList: normalizeImgList(item.imgUrlList),
     modelText: ANSWER_MODEL_MAP[item.model] || '',
     model: toNumber(item.model),
     score: item.score === '' || item.score == null ? '-' : item.score,
@@ -498,6 +573,12 @@ export default {
       ],
       selectedIndex: 0,
       detailTab: 'answer',
+      // 作答图片内嵌查看器:当前图片下标、缩放比例、平移位移、是否拖动中
+      answerImgIndex: 0,
+      answerImgScale: 1,
+      answerImgX: 0,
+      answerImgY: 0,
+      answerImgDragging: false,
       questionPageNum: 1,
       knowledgePageNum: 1,
       // 两个表格前端分页,固定每页 10 条
@@ -556,6 +637,14 @@ export default {
     selectedQuestion() {
       return this.chartQuestions[this.selectedIndex] || null;
     },
+    // 当前题作答留痕图片列表
+    answerImgList() {
+      return this.selectedQuestion ? this.selectedQuestion.imgUrlList : [];
+    },
+    // 查看器当前展示的图片地址
+    currentAnswerImg() {
+      return this.answerImgList[this.answerImgIndex] || '';
+    },
     questionPage() {
       const start = (this.questionPageNum - 1) * this.questionPageSize;
       return this.questionRows.slice(start, start + this.questionPageSize);
@@ -583,11 +672,19 @@ export default {
       return [['group', '得分率'], ...this.chartKnowledge.map(item => [item.name, item.rate])];
     },
   },
+  watch: {
+    // 切换小题时重置图片查看器到第一张、100% 缩放、居中无位移
+    selectedQuestion() {
+      this.answerImgIndex = 0;
+      this.resetAnswerImgView();
+    },
+  },
   created() {
     this.restoreState();
   },
   beforeDestroy() {
     clearTimeout(this.scrollTimer);
+    this.stopAnswerImgDrag();
   },
   methods: {
     restoreState() {
@@ -712,6 +809,99 @@ export default {
       this.selectedIndex = index;
       this.detailTab = 'answer';
     },
+    // 上一张(循环翻阅),切换后恢复 100% 并居中
+    prevAnswerImg() {
+      const len = this.answerImgList.length;
+      if (!len) return;
+      this.answerImgIndex = (this.answerImgIndex - 1 + len) % len;
+      this.resetAnswerImgView();
+    },
+    // 下一张(循环翻阅),切换后恢复 100% 并居中
+    nextAnswerImg() {
+      const len = this.answerImgList.length;
+      if (!len) return;
+      this.answerImgIndex = (this.answerImgIndex + 1) % len;
+      this.resetAnswerImgView();
+    },
+    // 点击底部数字跳转到指定图片
+    goAnswerImg(index) {
+      if (index === this.answerImgIndex || index < 0 || index >= this.answerImgList.length) return;
+      this.answerImgIndex = index;
+      this.resetAnswerImgView();
+    },
+    // 缩放范围 20% ~ 500%;缩放后把位置夹回可视范围,图片小于舞台的方向自动回正
+    zoomAnswerImg(delta) {
+      this.answerImgScale = Math.min(5, Math.max(0.2, Number((this.answerImgScale + delta).toFixed(2))));
+      const pos = this.clampAnswerImgPos(this.answerImgX, this.answerImgY);
+      this.answerImgX = pos.x;
+      this.answerImgY = pos.y;
+    },
+    // 鼠标滚轮在容器内直接缩放
+    onAnswerImgWheel(e) {
+      this.zoomAnswerImg(e.deltaY < 0 ? 0.15 : -0.15);
+    },
+    // 复位缩放与位移:100% 且居中
+    resetAnswerImgView() {
+      this.answerImgScale = 1;
+      this.answerImgX = 0;
+      this.answerImgY = 0;
+    },
+    // 判断事件目标是否落在翻页箭头、缩放条、页码等控件上
+    isViewerControlTarget(e) {
+      return !!(e.target && e.target.closest && e.target.closest('.viewer_arrow, .viewer_zoom, .viewer_index'));
+    },
+    // 双击舞台空白或图片均复位,双击控件不触发
+    onStageDblClick(e) {
+      if (this.isViewerControlTarget(e)) return;
+      this.resetAnswerImgView();
+    },
+    // 在舞台任意位置按下即可拖动图片(Pointer Events 同时兼容鼠标与触摸)
+    startAnswerImgDrag(e) {
+      // 只响应主键/触摸,避免右键等干扰
+      if (e.button != null && e.button !== 0) return;
+      // 点翻页箭头、缩放条、页码时不启动拖动
+      if (this.isViewerControlTarget(e)) return;
+      this.answerImgDragging = true;
+      this.answerImgDrag = {
+        startX: e.clientX,
+        startY: e.clientY,
+        baseX: this.answerImgX,
+        baseY: this.answerImgY,
+      };
+      window.addEventListener('pointermove', this.onAnswerImgDragMove);
+      window.addEventListener('pointerup', this.stopAnswerImgDrag);
+      window.addEventListener('pointercancel', this.stopAnswerImgDrag);
+    },
+    onAnswerImgDragMove(e) {
+      if (!this.answerImgDragging || !this.answerImgDrag) return;
+      const dx = e.clientX - this.answerImgDrag.startX;
+      const dy = e.clientY - this.answerImgDrag.startY;
+      // transform 为 translate() scale()、origin 居中,位移即屏幕像素增量,任意缩放都可自由拖动
+      this.answerImgX = this.answerImgDrag.baseX + dx;
+      this.answerImgY = this.answerImgDrag.baseY + dy;
+    },
+    // 松手结束拖动,并成对移除 window 监听,防止容器外松手导致状态卡死
+    stopAnswerImgDrag() {
+      if (!this.answerImgDragging) return;
+      this.answerImgDragging = false;
+      this.answerImgDrag = null;
+      window.removeEventListener('pointermove', this.onAnswerImgDragMove);
+      window.removeEventListener('pointerup', this.stopAnswerImgDrag);
+      window.removeEventListener('pointercancel', this.stopAnswerImgDrag);
+    },
+    // 缩放后位置夹取:图片未溢出舞台的方向位移归零(自动回中),溢出方向限制在边缘对齐范围内
+    clampAnswerImgPos(x, y) {
+      const stage = this.$refs.viewerStage;
+      const imgEl = stage && stage.querySelector('.viewer_img');
+      if (!stage || !imgEl) return { x, y };
+      // clientWidth/Height 为缩放前布局尺寸,乘 scale 才是当前渲染尺寸
+      const maxX = Math.max(0, (imgEl.clientWidth * this.answerImgScale - stage.clientWidth) / 2);
+      const maxY = Math.max(0, (imgEl.clientHeight * this.answerImgScale - stage.clientHeight) / 2);
+      return {
+        x: Math.min(maxX, Math.max(-maxX, x)),
+        y: Math.min(maxY, Math.max(-maxY, y)),
+      };
+    },
     // 判断富文本是否有实际内容:纯 <p><br></p> 等空标签视为空,含图片或文字视为有内容
     hasHtmlContent(content) {
       if (!content) return false;
@@ -794,8 +984,7 @@ export default {
       return tag.length > 5 ? `${tag.slice(0, 5)}…` : tag;
     },
     goBack() {
-      // 从查看分析返回时,列表定位到「已提交」页签
-      this.$router.push({ path: '/teachingHomework/list', query: { status: 1 } });
+      this.$router.push('/teachingHomework/list');
     },
   },
 };
@@ -993,18 +1182,22 @@ export default {
     align-items: stretch;
     gap: 10px;
     margin-bottom: 10px;
+    /* 限制详情区最大高度,文字或图片过多时由内部容器滚动,不撑大整卡 */
+    max-height: 600px;
 
     .detail_card {
       min-width: 0;
+      min-height: 0;
       margin-bottom: 0;
       display: flex;
       flex-direction: column;
+      overflow: hidden;
     }
 
     /* 左题干卡固定宽度 */
     .stem_card {
-      flex: 0 0 410px;
-      width: 410px;
+      flex: 0 0 480px;
+      width: 480px;
       padding: 20px;
     }
 
@@ -1012,6 +1205,7 @@ export default {
     .answer_col {
       flex: 1;
       min-width: 0;
+      min-height: 0;
       display: flex;
       flex-direction: column;
       gap: 10px;
@@ -1238,7 +1432,7 @@ export default {
 
     .detail_content {
       flex: 1;
-      min-height: 360px;
+      min-height: 240px;
       overflow-y: auto;
       padding: 16px;
       background: #F5F7FA;
@@ -1250,25 +1444,237 @@ export default {
     }
 
     .answer_content {
-      text-align: center;
       display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      text-align: left;
 
-      .answer_img {
-        display: block;
-        width: 100%;
-        max-width: 560px;
-        margin: 0 auto;
-        border-radius: 4px;
-        background: #fff;
+      /* 选择题只读作答:结构与样式和作答页 answer.vue 保持一致 */
+      .choice_view {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+      }
 
-        :deep(img) {
-          width: 100%;
-          height: auto;
+      .choice_label {
+        flex-shrink: 0;
+        font-size: 14px;
+        color: #333;
+      }
+
+      .choice_radio {
+        display: inline-flex;
+        align-items: center;
+        min-height: 20px;
+        gap: 20px;
+
+        :deep(.el-checkbox) {
+          margin-right: 0 !important;
+          height: 20px;
+          display: inline-flex !important;
+          align-items: center;
+          cursor: default;
+        }
+
+        :deep(.el-checkbox__input) {
+          display: inline-flex;
+          align-items: center;
+          height: 16px;
+          line-height: 16px;
+          cursor: default;
+        }
+
+        :deep(.el-checkbox__inner) {
+          width: 16px;
+          height: 16px;
+          border: 1px solid #DCDFE6;
+          background: #fff;
+          border-radius: 2px;
+
+          &::after {
+            top: 1px;
+            left: 5px;
+            width: 3px;
+            height: 8px;
+          }
+        }
+
+        :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
+        :deep(.el-checkbox__input.is-focus .el-checkbox__inner) {
+          background-color: #2E64FA;
+          box-shadow: none;
+          border-color: #2E64FA;
+        }
+
+        /* 禁用态仅用于展示:未选项白底,选中项仍为蓝底白勾,字母不变灰 */
+        :deep(.el-checkbox__input.is-disabled .el-checkbox__inner) {
+          background-color: #fff;
+          border-color: #DCDFE6;
+        }
+
+        :deep(.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner) {
+          background-color: #2E64FA;
+          border-color: #2E64FA;
+        }
+
+        :deep(.el-checkbox.is-disabled .el-checkbox__label) {
+          color: #333;
+          cursor: default;
+        }
+
+        :deep(.el-checkbox__label) {
+          display:inline-flex;
+          align-items: center;
+          height: 20px;
+          padding-left: 8px;
+          font-size: 14px;
+          color: #333;
+          line-height: 20px;
+        }
+      }
+
+      /* 非选择题作答图片:容器内嵌查看器,图片居中、可缩放、可翻阅 */
+      .img_viewer {
+        align-self: stretch;
+        flex: 1;
+        min-height: 0;
+        display: flex;
+        flex-direction: column;
+      }
+
+      .viewer_stage {
+        position: relative;
+        flex: 1;
+        min-height: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        overflow: hidden;
+        // background: #fff;
+        border-radius: 6px;
+        /* 整个舞台都是拖拽面:抓手光标 + 触摸手势由舞台接管 */
+        cursor: grab;
+        user-select: none;
+        touch-action: none;
+        /* 提升为独立合成层,保证 overflow 对内部 transform 图片的裁切实时生效,避免缩放残影 */
+        transform: translateZ(0);
+        isolation: isolate;
+
+        &.dragging {
+          cursor: grabbing;
+        }
+
+        .viewer_img {
           display: block;
+          max-width: 100%;
+          max-height: 100%;
+          object-fit: contain;
+          transform-origin: center center;
+          -webkit-user-drag: none;
+          pointer-events: none;
+          /* 独立合成层更新 transform,缩放拖动均即时渲染不留残影 */
+          will-change: transform;
+          backface-visibility: hidden;
         }
 
-        & + .answer_img {
-          margin-top: 12px;
+        .viewer_arrow {
+          position: absolute;
+          top: 50%;
+          transform: translateY(-50%);
+          width: 32px;
+          height: 32px;
+          padding: 0;
+          border: none;
+          border-radius: 50%;
+          background: rgba(0, 0, 0, 0.45);
+          color: #fff;
+          font-size: 16px;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+
+          &:hover {
+            background: rgba(0, 0, 0, 0.65);
+          }
+
+          &.left { left: 10px; }
+          &.right { right: 10px; }
+        }
+
+        .viewer_zoom {
+          position: absolute;
+          right: 10px;
+          bottom: 10px;
+          height: 28px;
+          padding: 0 6px;
+          display: inline-flex;
+          align-items: center;
+          gap: 2px;
+          background: rgba(0, 0, 0, 0.55);
+          border-radius: 16px;
+
+          .zoom_btn {
+            width: 24px;
+            height: 24px;
+            padding: 0;
+            border: none;
+            background: transparent;
+            color: #fff;
+            font-size: 13px;
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+
+            &:hover { color: #B3CDFF; }
+          }
+
+          .zoom_value {
+            min-width: 44px;
+            text-align: center;
+            color: #fff;
+            font-size: 12px;
+            cursor: pointer;
+          }
+        }
+
+        /* 页码悬浮舞台底部居中,不占据图片区高度;容器透传指针,仅数字本身可点 */
+        .viewer_index {
+          position: absolute;
+          left: 50%;
+          bottom: 12px;
+          transform: translateX(-50%);
+          display: flex;
+          flex-wrap: wrap;
+          justify-content: center;
+          gap: 10px;
+          max-width: calc(100% - 120px);
+          pointer-events: none;
+
+          .index_num {
+            width: 30px;
+            height: 30px;
+            border-radius: 6px;
+            background: rgba(242, 243, 245, 0.92);
+            box-sizing: border-box;
+            color: #4B5563;
+            font-size: 14px;
+            line-height: 30px;
+            text-align: center;
+            cursor: pointer;
+            pointer-events: auto;
+            transition: background-color 0.15s, color 0.15s;
+
+            &:hover {
+              color: #2E64FA;
+            }
+
+            &.active {
+              background: #2E64FA;
+              color: #fff;
+            }
+          }
         }
       }
     }
@@ -1341,6 +1747,8 @@ export default {
 
     .content_empty {
       flex: 1;
+      align-self: stretch;
+      width: 100%;
       min-height: 200px;
     }
   }

+ 98 - 57
src/views/teachingHomework/homeworkTask/answer.vue

@@ -29,10 +29,10 @@
         <div class="nav_group" v-for="group in questionGroups" :key="group.type">
           <div class="group_name">{{ group.name }}</div>
           <div class="num_grid">
-            <button v-for="item in group.list" :key="item.id" type="button" class="num_btn" :class="numClass(item)"
-              @click="selectQuestion(item.index)">
+            <!-- 序号仅展示作答进度,不支持点击跳题,只能按顺序逐题作答 -->
+            <span v-for="item in group.list" :key="item.id" class="num_btn" :class="numClass(item)">
               {{ item.index + 1 }}
-            </button>
+            </span>
           </div>
         </div>
       </div>
@@ -60,12 +60,12 @@
           <div class="dock_answer">
             <span class="dock_label">我的作答:</span>
             <span v-if="isSingle" class="dock_sub_no">(1)</span>
-            <el-radio-group v-if="isSingle" class="choice_radio" v-model="answers[currentQuestion.id]"
+            <el-checkbox-group v-if="isSingle" class="choice_radio" v-model="answers[currentQuestion.id]"
               @change="persistState">
-              <el-radio v-for="opt in currentQuestion.options" :key="opt.key" :label="opt.key">
+              <el-checkbox v-for="opt in currentQuestion.options" :key="opt.key" :label="opt.key">
                 {{ opt.key }}
-              </el-radio>
-            </el-radio-group>
+              </el-checkbox>
+            </el-checkbox-group>
             <div v-else class="photo_list">
               <div
                 class="photo_item"
@@ -79,7 +79,8 @@
           </div>
           <!-- 选择题禁用拍照;解答题可上传。最后一题把「下一题」换成「提交」 -->
           <div class="dock_actions">
-            <el-button class="photo_btn" :type="isSingle ? 'default' : 'primary'" :disabled="isSingle" :loading="uploading"
+            <el-button class="photo_btn" :type="isSingle ? 'default' : 'primary'"
+              :disabled="isSingle || photoLimitReached" :loading="uploading"
               @click="handlePhoto">
               <img class="camera_icon" :src="isSingle ? cameraGray : cameraWhite" alt="">
               拍照上传
@@ -94,7 +95,7 @@
               提交
             </el-button>
           </div>
-          <input ref="photoInput" class="photo_input" type="file" accept="image/*" @change="onPhotoChange">
+          <input ref="photoInput" class="photo_input" type="file" accept="image/*" multiple @change="onPhotoChange">
         </div>
       </div>
     </div>
@@ -117,6 +118,9 @@ const STORAGE_KEY = 'teachingHomeworkAnswer';
 
 const OPTION_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
 
+// 每道主观题最多上传的作答图片数量
+const MAX_PHOTOS = 4;
+
 function isChoiceQuestion(item) {
   return Number(item.isObjective) === 1 || Number(item.isJudge) === 1;
 }
@@ -160,7 +164,7 @@ export default {
       questions: [],
       loading: false,
       currentIndex: 0, // 当前题下标
-      answers: {}, // 选择题为字符串,拍照题为图片 dataURL 数组
+      answers: {}, // 选择题为所选选项 key 数组,拍照题为图片 url 数组
       imgVisible: false, // 是否打开图片放大预览
       previewIndex: 0, // 预览起始图片下标
       cameraGray: require('@/assets/homeworkTask/camera_999.png'),
@@ -222,6 +226,10 @@ export default {
       const val = this.answers[this.currentQuestion.id];
       return Array.isArray(val) ? val : [];
     },
+    // 当前题是否已达到图片上传上限
+    photoLimitReached() {
+      return this.currentPhotos.length >= MAX_PHOTOS;
+    },
     // 已作答题数,用于顶部进度
     answeredCount() {
       return this.questions.filter(item => this.isAnswered(item.id)).length;
@@ -321,14 +329,29 @@ export default {
         const hasSaved = savedAnswers[item.id] != null || savedAnswers[String(item.id)] != null;
         if (item.type === 'photo') {
           answers[item.id] = hasSaved && Array.isArray(saved) ? saved : item.writeUrls.slice();
-        } else if (hasSaved && !Array.isArray(saved)) {
-          answers[item.id] = saved;
         } else {
-          answers[item.id] = item.writeAnswer || '';
+          // 选择题多选,答案统一为选项 key 数组;兼容历史字符串缓存与接口回显
+          answers[item.id] = this.normalizeChoiceAnswer(hasSaved ? saved : item.writeAnswer, item.options);
         }
       });
       this.answers = answers;
     },
+    // 把接口/缓存里的答案归一化为合法选项 key 数组,兼容 "A,C"、"AC"、"A|C"、["A"] 等格式
+    normalizeChoiceAnswer(value, options) {
+      const validKeys = (options || []).map(opt => opt.key);
+      let keys = [];
+      if (Array.isArray(value)) {
+        keys = value;
+      } else if (value !== null && value !== undefined && String(value).trim() !== '') {
+        keys = String(value).toUpperCase().match(/[A-Z]/g) || [];
+      }
+      const result = [];
+      keys.forEach(key => {
+        const k = String(key);
+        if (validKeys.indexOf(k) !== -1 && result.indexOf(k) === -1) result.push(k);
+      });
+      return result;
+    },
     // 保存作业、当前题和答案,保证刷新不丢失
     persistState() {
       if (!this.homework) return;
@@ -364,11 +387,6 @@ export default {
         done: answered && item.index !== this.currentIndex,
       };
     },
-    // 点击左侧序号切换题目
-    selectQuestion(index) {
-      this.currentIndex = index;
-      this.persistState();
-    },
     // 上一题
     prevQuestion() {
       if (this.currentIndex <= 0) return;
@@ -384,32 +402,55 @@ export default {
         this.persistState();
       });
     },
-    // 选择题不可拍照;解答题打开本地选图
+    // 选择题不可拍照;解答题达到上限或上传中也不可再选
     handlePhoto() {
       if (this.isSingle || this.uploading) return;
+      if (this.currentPhotos.length >= MAX_PHOTOS) {
+        this.$message.warning(`最多上传${MAX_PHOTOS}张图片`);
+        return;
+      }
       const input = this.$refs.photoInput;
       if (input) input.click();
     },
-    // 上传图片后保存接口返回的文件地址
-    onPhotoChange(e) {
-      const file = e.target.files && e.target.files[0];
+    // 选择图片后校验剩余空位:超出上限整体拒绝不上传;合法数量内逐张顺序上传,成功一张追加一张
+    async onPhotoChange(e) {
+      const files = Array.from(e.target.files || []);
       e.target.value = '';
-      if (!file || !this.currentQuestion || this.isSingle) return;
-      const formData = new FormData();
-      formData.append('file', file);
+      if (!files.length || !this.currentQuestion || this.isSingle) return;
+      // 本次选择数量超过剩余空位时整体拒绝:不上传、不渲染,请用户重新选择
+      const remaining = MAX_PHOTOS - this.currentPhotos.length;
+      if (remaining <= 0) {
+        this.$message.warning(`最多上传${MAX_PHOTOS}张图片`);
+        return;
+      }
+      if (files.length > remaining) {
+        this.$message.warning(`最多上传${MAX_PHOTOS}张图片,当前还可上传${remaining}张,请重新选择`);
+        return;
+      }
       this.uploading = true;
-      this.$api.teachingHomework.uploadFilesSelect(formData).then(res => {
-        this.appendPhotoUrl(this.pickFileUrl(res));
-      }).catch(err => {
-        const body = this.pickBody(err);
-        const url = body ? this.pickFileUrl(body) : '';
+      let failed = 0;
+      for (const file of files) {
+        const url = await this.uploadOnePhoto(file);
         if (url) {
           this.appendPhotoUrl(url);
-          return;
+        } else {
+          failed += 1;
         }
-        this.$message.warning('图片上传失败');
-      }).finally(() => {
-        this.uploading = false;
+      }
+      this.uploading = false;
+      if (failed) {
+        this.$message.warning(failed === files.length ? '图片上传失败,请重试' : `有${failed}张图片上传失败,请重试`);
+      }
+    },
+    // 上传单个图片文件,成功返回地址,失败返回空字符串
+    uploadOnePhoto(file) {
+      const formData = new FormData();
+      formData.append('file', file);
+      return this.$api.teachingHomework.uploadFilesSelect(formData).then(res => {
+        return this.pickFileUrl(res);
+      }).catch(err => {
+        const body = this.pickBody(err);
+        return body ? this.pickFileUrl(body) : '';
       });
     },
     pickFileUrl(body) {
@@ -469,7 +510,8 @@ export default {
         homeWorkStuQuesId: question.homeWorkStuQuesId,
         questionId: question.questionId,
         commitFlag,
-        writeAnswer: isChoice ? (this.answers[question.id] || '') : '',
+        // 选择题多选,以英文逗号拼接所选选项;拍照题提交图片地址
+        writeAnswer: isChoice ? (this.answers[question.id] || []).join(',') : '',
         writeUrls: isChoice ? [] : (this.answers[question.id] || []),
       };
       this.saving = true;
@@ -641,17 +683,21 @@ export default {
           grid-template-columns: repeat(5, 32px);
           gap: 15px;
 
-          /* 未作答蓝框,已作答绿框,当前题蓝底白字 */
+          /* 未作答蓝框,已作答绿框,当前题蓝底白字;仅展示进度,不可点击跳题 */
           .num_btn {
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
             width: 32px;
             height: 32px;
             padding: 0;
             border-radius: 4px;
             border: 1px solid #2E64FA;
+            box-sizing: border-box;
             background: #fff;
             color: #2E64FA;
             font-size: 14px;
-            cursor: pointer;
+            cursor: default;
 
             &.done {
               border-color: #3BA272;
@@ -792,55 +838,50 @@ export default {
           .choice_radio {
             display: inline-flex;
             align-items: center;
-            height: 20px;
+            min-height: 20px;
             gap: 20px;
 
-            :deep(.el-radio) {
+            :deep(.el-checkbox) {
               margin-right: 0 !important;
               height: 20px;
-              line-height: 20px;
               display: inline-flex !important;
               align-items: center;
             }
 
-            :deep(.el-radio__input) {
+            :deep(.el-checkbox__input) {
               display: inline-flex;
               align-items: center;
               height: 16px;
               line-height: 16px;
-              vertical-align: middle;
             }
 
-            :deep(.el-radio__inner) {
+            :deep(.el-checkbox__inner) {
               width: 16px;
               height: 16px;
               border: 1px solid #DCDFE6;
               background: #fff;
-              vertical-align: middle;
+              border-radius: 2px;
 
               &::after {
-                width: 10px;
-                height: 10px;
-                background-color: #2E64FA;
-                border-radius: 50%;
-                transform: translate(-50%, -50%) scale(0);
+                top: 1px;
+                left: 5px;
+                width: 3px;
+                height: 8px;
               }
             }
 
-            :deep(.el-radio__input.is-checked .el-radio__inner) {
+            :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
+            :deep(.el-checkbox__input.is-focus .el-checkbox__inner) {
+              background-color: #2E64FA;
+              box-shadow: none;
               border-color: #2E64FA;
-              background: #fff;
-
-              &::after {
-                transform: translate(-50%, -50%) scale(1);
-              }
             }
 
-            :deep(.el-radio__input.is-checked + .el-radio__label) {
+            :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
               color: #333;
             }
 
-            :deep(.el-radio__label) {
+            :deep(.el-checkbox__label) {
               display: inline-flex;
               align-items: center;
               height: 20px;

+ 73 - 18
src/views/teachingHomework/homeworkTask/list.vue

@@ -1,8 +1,8 @@
 <template>
   <div class="teaching_homework_page">
-    <!-- 外层容器限制宽度,避免公共组件 width:100% 在 flex 下撑出横向滚动 -->
+    <!-- 教辅作业头部科目筛选:数据来自教辅作业接口,与靶向提升无关 -->
     <div class="subject_filter_wrap">
-      <TargetSubject />
+      <FiltersItem :filtersData="filtersData" @selectItem="onSubjectChange" />
     </div>
 
     <div class="homework_panel">
@@ -92,12 +92,11 @@
 </template>
 
 <script>
-import { mapState } from 'vuex';
-import TargetSubject from '@/views/targetImprove/components/TargetSubject.vue';
+import FiltersItem from '@/components/FiltersItem_ruoyan.vue';
 
 export default {
   name: 'TeachingHomeworkList',
-  components: { TargetSubject },
+  components: { FiltersItem },
   data() {
     return {
       submitStatus: 0, // 0未提交 1已提交
@@ -107,25 +106,76 @@ export default {
       pageNum: 1,
       pageSize: 10,
       total: 0,
+      // 当前选中科目(来自教辅作业自己的科目接口)
+      subjectCode: '',
+      subjectName: '',
+      filtersData: [
+        {
+          name: '科目名称',
+          value: '',
+          list: [],
+        },
+      ],
     };
   },
-  computed: {
-    ...mapState('target', ['subjectCode', 'subjectName']),
+  mounted() {
+    this.fetchSubjects();
   },
-  watch: {
-    subjectCode() {
+  methods: {
+    // 获取头部科目筛选数据,timeScope 固定传 4
+    fetchSubjects() {
+      this.$api.teachingHomework.queryStudentAidSubject({ timeScope: 4 }).then(res => {
+        this.applySubjects(res);
+      }).catch(err => {
+        const body = this.pickBody(err);
+        if (body) {
+          this.applySubjects(body);
+          return;
+        }
+        this.filtersData[0].list = [];
+      });
+    },
+    applySubjects(res) {
+      const rawList = this.extractSubjectList(res && res.data);
+      const list = rawList.map(item => ({
+        label: item.subjectName || item.courseName || item.name || '',
+        value: item.subjectCode || item.courseCode || item.code || '',
+      })).filter(item => item.value);
+      this.filtersData[0].list = list;
+      if (!list.length) {
+        this.subjectCode = '';
+        this.subjectName = '';
+        this.filtersData[0].value = '';
+        this.list = [];
+        this.total = 0;
+        return;
+      }
+      // 默认选中第一个科目
+      const first = list[0];
+      this.filtersData[0].value = first.value;
+      this.subjectCode = first.value;
+      this.subjectName = first.label;
+      this.fetchList();
+    },
+    // 兼容接口返回数组或 { subjectData: [] } / { list: [] } 等结构
+    extractSubjectList(data) {
+      if (Array.isArray(data)) return data;
+      if (!data || typeof data !== 'object') return [];
+      if (Array.isArray(data.subjectData)) return data.subjectData;
+      if (Array.isArray(data.subjectList)) return data.subjectList;
+      if (Array.isArray(data.list)) return data.list;
+      if (Array.isArray(data.records)) return data.records;
+      return [];
+    },
+    // 切换头部科目
+    onSubjectChange(e) {
+      const item = this.filtersData[0].list.find(opt => opt.value === e.value);
+      this.filtersData[0].value = e.value;
+      this.subjectCode = e.value;
+      this.subjectName = item ? item.label : '';
       this.pageNum = 1;
       this.fetchList();
     },
-  },
-  mounted() {
-    // 从「查看作答 / 查看分析」返回时带 status=1,列表默认展示「已提交」
-    if (Number(this.$route.query.status) === 1) {
-      this.submitStatus = 1;
-    }
-    this.fetchList();
-  },
-  methods: {
     getSubjectColor(name) {
       const subjectName = name || this.subjectName || '';
       return this.$global.getSubjectsName(subjectName, null, 'color');
@@ -243,6 +293,11 @@ export default {
   min-width: 0;
   box-sizing: border-box;
   overflow: hidden;
+  height: 48px;
+  background: #fff;
+  border-radius: 10px;
+  padding: 2px 10px 0 20px;
+  margin-bottom: 10px;
 }
 
 .homework_panel {

+ 1 - 2
src/views/teachingHomework/homeworkTask/viewAnswer.vue

@@ -490,8 +490,7 @@ export default {
     },
     goBack() {
       this.persistState();
-      // 从查看作答返回时,列表定位到「已提交」页签
-      this.$router.push({ path: '/teachingHomework/list', query: { status: 1 } });
+      this.$router.push('/teachingHomework/list');
     },
   },
 };