answer.vue 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  1. <template>
  2. <!-- 教辅作业作答页。路由固定为 /teachingHomework/answer,不使用地址栏参数 -->
  3. <div class="homework_answer" v-if="homework">
  4. <!-- 顶部:返回、科目、任务信息、倒计时、作答进度 -->
  5. <div class="answer_header">
  6. <el-button class="back_btn" @click="goBack">
  7. <i class="iconfont icon_return"></i>返回
  8. </el-button>
  9. <div class="header_main">
  10. <div class="title_row">
  11. <span class="subject_badge" :style="{ background: subjectColor }">{{ homework.subjectName }}</span>
  12. <span class="task_title">{{ taskTitle }}</span>
  13. </div>
  14. <div class="meta_row">
  15. <span>布置教师:{{ homework.teacherName }}</span>
  16. <span>试题数量:{{ questions.length }}</span>
  17. <span>结束时间:{{ homework.endTime || '-' }}</span>
  18. <span class="countdown">倒计时:{{ countdownText }}</span>
  19. <span class="progress">作答进度:{{ answeredCount }}/{{ questions.length }}</span>
  20. </div>
  21. </div>
  22. </div>
  23. <!-- 主体:左侧试题序号,右侧题干 + 底部固定作答区 -->
  24. <div class="answer_body" v-loading="loading">
  25. <!-- 试题列表:单选题 / 解答题分组,序号可切换当前题 -->
  26. <div class="question_nav">
  27. <div class="nav_title">试题列表</div>
  28. <div class="nav_group" v-for="group in questionGroups" :key="group.type">
  29. <div class="group_name">{{ group.name }}</div>
  30. <div class="num_grid">
  31. <button
  32. v-for="item in group.list"
  33. :key="item.id"
  34. type="button"
  35. class="num_btn"
  36. :class="numClass(item)"
  37. @click="selectQuestion(item.index)">
  38. {{ item.index + 1 }}
  39. </button>
  40. </div>
  41. </div>
  42. </div>
  43. <!-- 右侧题目:上方滚动题干,下方作答区不随题干滚动 -->
  44. <div class="question_panel" v-if="currentQuestion">
  45. <div class="question_scroll">
  46. <div class="q_tags">
  47. <span class="q_no">{{ currentQuestion.index + 1 }}</span>
  48. <span class="q_type">{{ currentQuestion.typeName }}</span>
  49. <span class="q_level">{{ currentQuestion.difficulty }}</span>
  50. </div>
  51. <div class="q_stem" v-html="currentQuestion.stem"></div>
  52. <div v-if="currentQuestion.type === 'single' && hasOptionText" class="q_options">
  53. <div class="q_option" v-for="opt in currentQuestion.options" :key="opt.key">
  54. <span class="opt_key">{{ opt.key }}.</span>
  55. <span>{{ opt.text }}</span>
  56. </div>
  57. </div>
  58. </div>
  59. <!-- 作答区固定在题目卡片底部。选择题选选项,解答题只拍照,没有手写输入框 -->
  60. <div class="answer_dock">
  61. <!-- 选择题:我的作答 + 选项;解答题:已上传图片,可删除 -->
  62. <div class="dock_answer">
  63. <span class="dock_label">我的作答:</span>
  64. <!-- <span v-if="isSingle" class="dock_sub_no">(1)</span> -->
  65. <el-radio-group v-if="isJudge" class="choice_radio" v-model="answers[currentQuestion.id]"
  66. @change="persistState">
  67. <el-radio label="T">对</el-radio>
  68. <el-radio label="F">错</el-radio>
  69. </el-radio-group>
  70. <el-checkbox-group v-else-if="isSingle" class="choice_radio" v-model="answers[currentQuestion.id]"
  71. @change="persistState">
  72. <el-checkbox v-for="opt in currentQuestion.options" :key="opt.key" :label="opt.key">
  73. {{ opt.key }}
  74. </el-checkbox>
  75. </el-checkbox-group>
  76. <div v-else class="photo_list">
  77. <div
  78. class="photo_item"
  79. v-for="(img, imgIndex) in currentPhotos"
  80. :key="imgIndex"
  81. :class="{ active: imgIndex === 0 }">
  82. <img :src="img" alt="" @click="previewPhoto(imgIndex)">
  83. <i class="el-icon-close photo_remove" @click.stop="removePhoto(imgIndex)"></i>
  84. </div>
  85. </div>
  86. </div>
  87. <!-- 判断题、单选题、多选题不显示拍照;解答题可上传。最后一题把「下一题」换成「提交」 -->
  88. <div class="dock_actions">
  89. <el-button v-if="!isSingle" class="photo_btn" type="primary"
  90. :disabled="photoLimitReached" :loading="uploading"
  91. @click="handlePhoto">
  92. <img class="camera_icon" :src="cameraWhite" alt="">
  93. 拍照上传
  94. </el-button>
  95. <el-button class="nav_btn" :disabled="currentQuestion.index === 0" @click="prevQuestion">
  96. 上一题
  97. </el-button>
  98. <el-button v-if="!isLastQuestion" class="nav_btn" :loading="saving" @click="nextQuestion">
  99. 下一题
  100. </el-button>
  101. <el-button v-else class="nav_btn" :loading="saving" @click="submitAnswer">
  102. 提交
  103. </el-button>
  104. </div>
  105. <input ref="photoInput" class="photo_input" type="file" accept="image/*" multiple @change="onPhotoChange">
  106. </div>
  107. </div>
  108. </div>
  109. <!-- 图片放大预览:支持缩放、旋转,复用 Element UI ImageViewer -->
  110. <el-image-viewer
  111. v-if="imgVisible"
  112. :z-index="9999"
  113. :initial-index="previewIndex"
  114. :url-list="currentPhotos"
  115. :on-close="closePhotoPreview" />
  116. </div>
  117. </template>
  118. <script>
  119. import ElImageViewer from 'element-ui/packages/image/src/image-viewer';
  120. // 作答状态只存 sessionStorage,刷新仍停留本页,且地址栏不带参数
  121. const STORAGE_KEY = 'teachingHomeworkAnswer';
  122. const OPTION_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  123. // 每道主观题最多上传的作答图片数量
  124. const MAX_PHOTOS = 4;
  125. function isChoiceQuestion(item) {
  126. return Number(item.isObjective) === 1 || Number(item.isJudge) === 1;
  127. }
  128. function buildOptions(item) {
  129. const count = Number(item.optionsNum) || (Number(item.isJudge) === 1 ? 2 : 4);
  130. const options = [];
  131. for (let i = 0; i < count && i < OPTION_KEYS.length; i += 1) {
  132. options.push({ key: OPTION_KEYS.charAt(i), text: '' });
  133. }
  134. return options;
  135. }
  136. function mapQuestion(item, index) {
  137. const judge = Number(item.isJudge) === 1;
  138. const choice = judge || isChoiceQuestion(item);
  139. const id = item.homeWorkStuQuesId || item.questionId || index;
  140. return {
  141. id,
  142. questionId: item.questionId,
  143. homeWorkStuQuesId: item.homeWorkStuQuesId,
  144. isJudge: judge,
  145. type: choice ? 'single' : 'photo',
  146. typeName: item.questionTypeName || (judge ? '判断题' : (choice ? '客观题' : '主观题')),
  147. groupName: item.questionTypeName || (judge ? '判断题' : (choice ? '客观题' : '主观题')),
  148. difficulty: item.difficultyName || '',
  149. stem: item.questionContent || '',
  150. options: judge ? [] : (choice ? buildOptions(item) : []),
  151. writeAnswer: item.writeAnswer || '',
  152. writeUrls: Array.isArray(item.writeUrls) ? item.writeUrls : [],
  153. submitEndTime: item.submitEndTime || '',
  154. };
  155. }
  156. export default {
  157. name: 'TeachingHomeworkAnswer',
  158. components: {
  159. ElImageViewer,
  160. },
  161. data() {
  162. return {
  163. homework: null, // 当前作业任务,来自 sessionStorage
  164. questions: [],
  165. loading: false,
  166. currentIndex: 0, // 当前题下标
  167. answers: {}, // 选择题为所选选项 key 数组,拍照题为图片 url 数组
  168. imgVisible: false, // 是否打开图片放大预览
  169. previewIndex: 0, // 预览起始图片下标
  170. cameraWhite: require('@/assets/homeworkTask/camera_999.png'),
  171. nowTick: Date.now(),
  172. timer: null,
  173. uploading: false,
  174. saving: false,
  175. };
  176. },
  177. computed: {
  178. // 标题后拼接班级,名称里已有班级则不重复加
  179. taskTitle() {
  180. if (!this.homework) return '';
  181. const name = this.homework.homeworkName || '';
  182. const className = this.homework.className || '';
  183. if (!className || name.indexOf(className) !== -1) return name;
  184. return `${name} ${className}`;
  185. },
  186. // 科目色标,与靶向提升科目颜色一致
  187. subjectColor() {
  188. const name = (this.homework && this.homework.subjectName) || '';
  189. return this.$global.getSubjectsName(name, null, 'color');
  190. },
  191. // 按接口返回的 questionTypeName 分组,有几种题型就显示几组
  192. questionGroups() {
  193. const groups = [];
  194. this.questions.forEach((item, index) => {
  195. const name = item.groupName || item.typeName || '其他';
  196. let group = groups.find(g => g.name === name);
  197. if (!group) {
  198. group = { type: name, name, list: [] };
  199. groups.push(group);
  200. }
  201. group.list.push({ ...item, index });
  202. });
  203. return groups;
  204. },
  205. // 当前正在作答的题目
  206. currentQuestion() {
  207. const item = this.questions[this.currentIndex];
  208. if (!item) return null;
  209. return { ...item, index: this.currentIndex };
  210. },
  211. // 当前题是否为选择题(含判断题)
  212. isSingle() {
  213. return !!(this.currentQuestion && this.currentQuestion.type === 'single');
  214. },
  215. // isJudge 为 1 时是判断题,只显示对 / 错
  216. isJudge() {
  217. return !!(this.currentQuestion && this.currentQuestion.isJudge);
  218. },
  219. hasOptionText() {
  220. const options = (this.currentQuestion && this.currentQuestion.options) || [];
  221. return options.some(opt => opt.text);
  222. },
  223. // 是否最后一题,最后一题显示「提交」
  224. isLastQuestion() {
  225. return this.currentIndex >= this.questions.length - 1;
  226. },
  227. // 当前拍照题已上传的图片
  228. currentPhotos() {
  229. if (!this.currentQuestion || this.isSingle) return [];
  230. const val = this.answers[this.currentQuestion.id];
  231. return Array.isArray(val) ? val : [];
  232. },
  233. // 当前题是否已达到图片上传上限
  234. photoLimitReached() {
  235. return this.currentPhotos.length >= MAX_PHOTOS;
  236. },
  237. // 已作答题数,用于顶部进度
  238. answeredCount() {
  239. return this.questions.filter(item => this.isAnswered(item.id)).length;
  240. },
  241. // 倒计时:截止日期减去当前时间,按天、时、分、秒显示,并每秒刷新
  242. countdownText() {
  243. const endTime = this.homework && this.homework.endTime;
  244. if (!endTime) return '-';
  245. const text = String(endTime).trim();
  246. const end = new Date(text.replace(/-/g, '/'));
  247. if (!Number.isFinite(end.getTime())) return '-';
  248. if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
  249. end.setHours(23, 59, 59, 0);
  250. }
  251. const diff = Math.max(0, end.getTime() - this.nowTick);
  252. const day = Math.floor(diff / 86400000);
  253. const hour = Math.floor((diff % 86400000) / 3600000);
  254. const minute = Math.floor((diff % 3600000) / 60000);
  255. const second = Math.floor((diff % 60000) / 1000);
  256. const pad = value => String(value).padStart(2, '0');
  257. return `${day}天 ${pad(hour)}:${pad(minute)}:${pad(second)}`;
  258. },
  259. },
  260. created() {
  261. this.restoreState();
  262. this.startTimer();
  263. },
  264. beforeDestroy() {
  265. this.clearTimer();
  266. this.persistState();
  267. },
  268. methods: {
  269. // 进入页面或刷新时恢复作答;没有作业记录则回到列表
  270. restoreState() {
  271. let cache = null;
  272. try {
  273. cache = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null');
  274. } catch (e) {
  275. cache = null;
  276. }
  277. if (!cache || !cache.homework) {
  278. this.$router.replace('/teachingHomework/list');
  279. return;
  280. }
  281. this.homework = cache.homework;
  282. this.currentIndex = Number(cache.currentIndex) || 0;
  283. this.savedAnswers = cache.answers && typeof cache.answers === 'object' ? cache.answers : {};
  284. this.fetchDetail();
  285. },
  286. fetchDetail() {
  287. const homework = this.homework || {};
  288. if (!homework.homeWorkStuId && !homework.homeWorkId) {
  289. this.questions = [];
  290. return;
  291. }
  292. this.loading = true;
  293. const params = {
  294. homeWorkStuId: homework.homeWorkStuId,
  295. homeWorkId: homework.homeWorkId,
  296. };
  297. this.$api.teachingHomework.viewHomeWorkWriteDetail(params).then(res => {
  298. this.applyDetail(res);
  299. }).catch(err => {
  300. const body = this.pickBody(err);
  301. if (body) {
  302. this.applyDetail(body);
  303. return;
  304. }
  305. this.questions = [];
  306. }).finally(() => {
  307. this.loading = false;
  308. });
  309. },
  310. pickBody(payload) {
  311. if (!payload) return null;
  312. if (payload.code === 0 || payload.code === 200) return payload;
  313. if (payload.data && (payload.data.code === 0 || payload.data.code === 200)) return payload.data;
  314. if (payload.response) return this.pickBody(payload.response);
  315. return null;
  316. },
  317. applyDetail(res) {
  318. const detail = this.pickDetail(res);
  319. const list = detail.questionDetails;
  320. this.mergeHomework(detail);
  321. this.questions = list.map(mapQuestion);
  322. const endTime = detail.submitEndTime
  323. || (this.questions[0] && this.questions[0].submitEndTime)
  324. || (this.homework && this.homework.endTime);
  325. if (endTime && this.homework) this.homework.endTime = endTime;
  326. this.fillAnswers();
  327. if (this.currentIndex < 0 || this.currentIndex >= this.questions.length) {
  328. this.currentIndex = 0;
  329. }
  330. this.persistState();
  331. },
  332. // 详情 data 为作业对象,题目在 questionDetails;兼容旧的题目数组
  333. pickDetail(res) {
  334. const data = res && res.data;
  335. if (Array.isArray(data)) return { questionDetails: data };
  336. if (data && typeof data === 'object') {
  337. return {
  338. ...data,
  339. questionDetails: Array.isArray(data.questionDetails) ? data.questionDetails : [],
  340. };
  341. }
  342. return { questionDetails: [] };
  343. },
  344. // 用详情里的作业信息补齐头部:名称、教师、科目、截止时间
  345. mergeHomework(detail) {
  346. const current = this.homework || {};
  347. this.homework = {
  348. ...current,
  349. homeWorkId: detail.homeWorkId != null ? detail.homeWorkId : current.homeWorkId,
  350. homeWorkStuId: detail.homeWorkStuId != null ? detail.homeWorkStuId : current.homeWorkStuId,
  351. homeworkName: detail.homeWorkName || current.homeworkName || '',
  352. teacherName: detail.createUserName || current.teacherName || '',
  353. subjectName: detail.courseName || current.subjectName || '',
  354. courseCode: detail.courseCode != null ? detail.courseCode : current.courseCode,
  355. endTime: detail.submitEndTime || current.endTime || '',
  356. questionNum: detail.questionNumber != null ? detail.questionNumber : current.questionNum,
  357. };
  358. },
  359. fillAnswers() {
  360. const savedAnswers = this.savedAnswers || {};
  361. const answers = {};
  362. this.questions.forEach(item => {
  363. const saved = savedAnswers[item.id] != null ? savedAnswers[item.id] : savedAnswers[String(item.id)];
  364. const hasSaved = savedAnswers[item.id] != null || savedAnswers[String(item.id)] != null;
  365. if (item.type === 'photo') {
  366. answers[item.id] = hasSaved && Array.isArray(saved) ? saved : item.writeUrls.slice();
  367. } else if (item.isJudge) {
  368. answers[item.id] = this.normalizeJudgeAnswer(hasSaved ? saved : item.writeAnswer);
  369. } else {
  370. // 选择题多选,答案统一为选项 key 数组;兼容历史字符串缓存与接口回显
  371. answers[item.id] = this.normalizeChoiceAnswer(hasSaved ? saved : item.writeAnswer, item.options);
  372. }
  373. });
  374. this.answers = answers;
  375. },
  376. // 判断题回显:T 为对,F 为错
  377. normalizeJudgeAnswer(value) {
  378. const text = String(value == null ? '' : value).trim();
  379. if (text === 'T' || text === 't' || text === '对' || text.toLowerCase() === 'true') return 'T';
  380. if (text === 'F' || text === 'f' || text === '错' || text.toLowerCase() === 'false') return 'F';
  381. return '';
  382. },
  383. // 把接口/缓存里的答案归一化为合法选项 key 数组,兼容 "A,C"、"AC"、"A|C"、["A"] 等格式
  384. normalizeChoiceAnswer(value, options) {
  385. const validKeys = (options || []).map(opt => opt.key);
  386. let keys = [];
  387. if (Array.isArray(value)) {
  388. keys = value;
  389. } else if (value !== null && value !== undefined && String(value).trim() !== '') {
  390. keys = String(value).toUpperCase().match(/[A-Z]/g) || [];
  391. }
  392. const result = [];
  393. keys.forEach(key => {
  394. const k = String(key);
  395. if (validKeys.indexOf(k) !== -1 && result.indexOf(k) === -1) result.push(k);
  396. });
  397. return result;
  398. },
  399. // 保存作业、当前题和答案,保证刷新不丢失
  400. persistState() {
  401. if (!this.homework) return;
  402. sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
  403. homework: this.homework,
  404. currentIndex: this.currentIndex,
  405. answers: this.answers,
  406. }));
  407. },
  408. startTimer() {
  409. this.clearTimer();
  410. this.timer = setInterval(() => {
  411. this.nowTick = Date.now();
  412. }, 1000);
  413. },
  414. clearTimer() {
  415. if (this.timer) {
  416. clearInterval(this.timer);
  417. this.timer = null;
  418. }
  419. },
  420. // 选择题有选项、拍照题有图片,即视为已作答
  421. isAnswered(id) {
  422. const val = this.answers[id];
  423. if (Array.isArray(val)) return val.length > 0;
  424. return val !== undefined && val !== null && String(val).trim() !== '';
  425. },
  426. // 序号样式:当前题蓝底,已作答绿框,未作答蓝框
  427. numClass(item) {
  428. const answered = this.isAnswered(item.id);
  429. return {
  430. current: item.index === this.currentIndex,
  431. done: answered && item.index !== this.currentIndex,
  432. };
  433. },
  434. // 切题:当前题有作答才保存,没作答或为空则直接切换、不调接口
  435. selectQuestion(index) {
  436. if (index === this.currentIndex) return;
  437. this.goToQuestion(index);
  438. },
  439. prevQuestion() {
  440. if (this.currentIndex <= 0) return;
  441. this.goToQuestion(this.currentIndex - 1);
  442. },
  443. nextQuestion() {
  444. if (this.currentIndex >= this.questions.length - 1) return;
  445. this.goToQuestion(this.currentIndex + 1);
  446. },
  447. goToQuestion(index) {
  448. if (index === this.currentIndex) return;
  449. if (this.uploading) {
  450. this.$message.warning('图片正在上传,请稍候');
  451. return;
  452. }
  453. const jump = () => {
  454. this.currentIndex = index;
  455. this.persistState();
  456. };
  457. const question = this.currentQuestion;
  458. if (!question || !this.isAnswered(question.id)) {
  459. jump();
  460. return;
  461. }
  462. this.saveCurrentAnswer(0).then(ok => {
  463. if (!ok) return;
  464. jump();
  465. });
  466. },
  467. // 选择题不可拍照;解答题达到上限或上传中也不可再选
  468. handlePhoto() {
  469. if (this.isSingle || this.uploading) return;
  470. if (this.currentPhotos.length >= MAX_PHOTOS) {
  471. this.$message.warning(`最多上传${MAX_PHOTOS}张图片`);
  472. return;
  473. }
  474. const input = this.$refs.photoInput;
  475. if (input) input.click();
  476. },
  477. // 选择图片后校验剩余空位:超出上限整体拒绝不上传;合法数量内逐张顺序上传,成功一张追加一张
  478. async onPhotoChange(e) {
  479. const files = Array.from(e.target.files || []);
  480. e.target.value = '';
  481. if (!files.length || !this.currentQuestion || this.isSingle) return;
  482. // 本次选择数量超过剩余空位时整体拒绝:不上传、不渲染,请用户重新选择
  483. const remaining = MAX_PHOTOS - this.currentPhotos.length;
  484. if (remaining <= 0) {
  485. this.$message.warning(`最多上传${MAX_PHOTOS}张图片`);
  486. return;
  487. }
  488. if (files.length > remaining) {
  489. this.$message.warning(`最多上传${MAX_PHOTOS}张图片,当前还可上传${remaining}张,请重新选择`);
  490. return;
  491. }
  492. this.uploading = true;
  493. let failed = 0;
  494. for (const file of files) {
  495. const url = await this.uploadOnePhoto(file);
  496. if (url) {
  497. this.appendPhotoUrl(url);
  498. } else {
  499. failed += 1;
  500. }
  501. }
  502. this.uploading = false;
  503. if (failed) {
  504. this.$message.warning(failed === files.length ? '图片上传失败,请重试' : `有${failed}张图片上传失败,请重试`);
  505. }
  506. },
  507. // 上传单个图片文件,成功返回地址,失败返回空字符串
  508. uploadOnePhoto(file) {
  509. const formData = new FormData();
  510. formData.append('file', file);
  511. return this.$api.teachingHomework.uploadFilesSelect(formData).then(res => {
  512. return this.pickFileUrl(res);
  513. }).catch(err => {
  514. const body = this.pickBody(err);
  515. return body ? this.pickFileUrl(body) : '';
  516. });
  517. },
  518. pickFileUrl(body) {
  519. const data = body && body.data;
  520. if (!data) return '';
  521. if (typeof data === 'string') return data;
  522. if (Array.isArray(data)) {
  523. const first = data[0];
  524. if (typeof first === 'string') return first;
  525. return (first && (first.url || first.fileUrl || first.path || first.filePath)) || '';
  526. }
  527. return data.url || data.fileUrl || data.path || data.filePath || '';
  528. },
  529. appendPhotoUrl(url) {
  530. if (!url || !this.currentQuestion) {
  531. this.$message.warning('图片上传失败');
  532. return;
  533. }
  534. const id = this.currentQuestion.id;
  535. const list = Array.isArray(this.answers[id]) ? this.answers[id].slice() : [];
  536. list.push(url);
  537. this.$set(this.answers, id, list);
  538. this.persistState();
  539. },
  540. // 删除当前拍照题中的一张图片
  541. removePhoto(index) {
  542. if (!this.currentQuestion) return;
  543. const id = this.currentQuestion.id;
  544. const list = Array.isArray(this.answers[id]) ? this.answers[id].slice() : [];
  545. list.splice(index, 1);
  546. this.$set(this.answers, id, list);
  547. this.persistState();
  548. },
  549. // 点击缩略图放大预览(可缩放、旋转)
  550. previewPhoto(index) {
  551. this.previewIndex = index;
  552. this.imgVisible = true;
  553. },
  554. closePhotoPreview() {
  555. this.imgVisible = false;
  556. },
  557. // 保存当前题;空作答不走这里
  558. saveCurrentAnswer(commitFlag) {
  559. const question = this.currentQuestion;
  560. if (!question || this.saving || this.uploading) {
  561. if (this.uploading) this.$message.warning('图片正在上传,请稍候');
  562. return Promise.resolve(false);
  563. }
  564. const isJudge = !!question.isJudge;
  565. const isChoice = question.type === 'single' && !isJudge;
  566. const judgeValue = this.answers[question.id];
  567. const payload = {
  568. homeWorkId: this.homework && this.homework.homeWorkId,
  569. homeWorkStuId: this.homework && this.homework.homeWorkStuId,
  570. homeWorkStuQuesId: question.homeWorkStuQuesId,
  571. questionId: question.questionId,
  572. commitFlag,
  573. // 判断题传 T / F;选择题多选直接拼接,如 AB;拍照题提交图片地址
  574. writeAnswer: isJudge
  575. ? (judgeValue === 'T' || judgeValue === 'F' ? judgeValue : '')
  576. : (isChoice ? (this.answers[question.id] || []).join('') : ''),
  577. writeUrls: isJudge || isChoice ? [] : (this.answers[question.id] || []),
  578. };
  579. this.saving = true;
  580. return this.$api.teachingHomework.commitHomeWorkWriteAnswer(payload).then(res => {
  581. if (res && res.code != null && res.code !== 0 && res.code !== 200) {
  582. this.$message.warning(res.msg || '保存失败');
  583. return false;
  584. }
  585. return true;
  586. }).catch(err => {
  587. const body = this.pickBody(err);
  588. if (body) return true;
  589. this.$message.warning((err && err.data && err.data.msg) || '保存失败');
  590. return false;
  591. }).finally(() => {
  592. this.saving = false;
  593. });
  594. },
  595. // 提交时检查全部题目,有未作答则提示并跳到第一道未作答;全部作答完再提交
  596. submitAnswer() {
  597. if (this.uploading) {
  598. this.$message.warning('图片正在上传,请稍候');
  599. return;
  600. }
  601. const unanswered = this.questions
  602. .map((item, index) => ({ ...item, index }))
  603. .filter(item => !this.isAnswered(item.id));
  604. if (unanswered.length) {
  605. const nums = unanswered.map(item => item.index + 1).join('、');
  606. this.$message.warning(`第${nums}题没有上传答案,请作答`);
  607. this.currentIndex = unanswered[0].index;
  608. this.persistState();
  609. return;
  610. }
  611. this.saveCurrentAnswer(1).then(ok => {
  612. if (!ok) return;
  613. this.persistState();
  614. this.$message.success('提交成功');
  615. this.$router.push('/teachingHomework/list');
  616. });
  617. },
  618. // 返回列表,作答进度仍保留在 sessionStorage
  619. goBack() {
  620. this.persistState();
  621. this.$router.push('/teachingHomework/list');
  622. },
  623. },
  624. };
  625. </script>
  626. <style lang="scss" scoped>
  627. /* 整页纵向:顶栏固定,下方主体占满剩余高度 */
  628. .homework_answer {
  629. display: flex;
  630. flex-direction: column;
  631. width: 100%;
  632. height: 100%;
  633. min-width: 0;
  634. min-height: 0;
  635. box-sizing: border-box;
  636. overflow: hidden;
  637. .answer_header {
  638. flex-shrink: 0;
  639. display: flex;
  640. align-items: flex-start;
  641. gap: 16px;
  642. padding: 10px;
  643. background: #fff;
  644. border-radius: 10px;
  645. box-sizing: border-box;
  646. .back_btn {
  647. flex-shrink: 0;
  648. height: 36px;
  649. padding: 0 12px;
  650. background: #fff;
  651. border: 1px solid #DCDFE6;
  652. color: #666;
  653. :deep(span) {
  654. display: inline-flex;
  655. align-items: center;
  656. gap: 2px;
  657. }
  658. }
  659. .header_main {
  660. flex: 1;
  661. min-width: 0;
  662. .title_row {
  663. display: flex;
  664. align-items: center;
  665. gap: 10px;
  666. margin-bottom: 10px;
  667. margin-top: 6px;
  668. .subject_badge {
  669. flex-shrink: 0;
  670. display: inline-flex;
  671. align-items: center;
  672. justify-content: center;
  673. min-width: 40px;
  674. height: 25px;
  675. padding: 0 4px;
  676. border-radius: 4px;
  677. color: #fff;
  678. font-size: 12px;
  679. line-height: 1;
  680. }
  681. .task_title {
  682. font-size: 16px;
  683. font-weight: 550;
  684. color: #333;
  685. overflow: hidden;
  686. text-overflow: ellipsis;
  687. white-space: nowrap;
  688. }
  689. }
  690. .meta_row {
  691. display: flex;
  692. flex-wrap: wrap;
  693. gap: 16px;
  694. font-size: 14px;
  695. color: #999;
  696. .countdown {
  697. color: #F56C6C;
  698. }
  699. .progress {
  700. color: #2E64FA;
  701. }
  702. }
  703. }
  704. }
  705. /* 左导航 + 右题目 */
  706. .answer_body {
  707. flex: 1;
  708. min-height: 0;
  709. display: flex;
  710. gap: 10px;
  711. margin-top: 10px;
  712. overflow: hidden;
  713. .question_nav {
  714. flex-shrink: 0;
  715. width: 240px;
  716. height: 100%;
  717. background: #fff;
  718. border-radius: 10px;
  719. box-sizing: border-box;
  720. overflow-y: auto;
  721. .nav_title {
  722. font-size: 14px;
  723. font-weight: 550;
  724. color: #333;
  725. padding: 10px;
  726. border-bottom: 1px solid #EBEEF5;
  727. }
  728. .nav_group {
  729. padding: 10px;
  730. &+.nav_group {
  731. margin-top: 24px;
  732. }
  733. .group_name {
  734. font-size: 14px;
  735. color: #333;
  736. font-weight: 550;
  737. padding-bottom: 12px;
  738. }
  739. .num_grid {
  740. display: grid;
  741. grid-template-columns: repeat(5, 32px);
  742. gap: 15px;
  743. /* 未作答蓝框,已作答绿框,当前题蓝底白字;点击可跳题 */
  744. .num_btn {
  745. display: inline-flex;
  746. align-items: center;
  747. justify-content: center;
  748. width: 32px;
  749. height: 32px;
  750. padding: 0;
  751. border-radius: 4px;
  752. border: 1px solid #2E64FA;
  753. box-sizing: border-box;
  754. background: #fff;
  755. color: #2E64FA;
  756. font-size: 14px;
  757. cursor: pointer;
  758. &.done {
  759. border-color: #3BA272;
  760. color: #3BA272;
  761. }
  762. &.current {
  763. background: #2E64FA;
  764. border-color: #2E64FA;
  765. color: #fff;
  766. }
  767. }
  768. }
  769. }
  770. }
  771. .question_panel {
  772. flex: 1;
  773. min-width: 0;
  774. height: 100%;
  775. display: flex;
  776. flex-direction: column;
  777. background: #fff;
  778. border-radius: 10px;
  779. overflow: hidden;
  780. .question_scroll {
  781. flex: 1;
  782. min-height: 0;
  783. overflow-y: auto;
  784. padding: 16px;
  785. box-sizing: border-box;
  786. .q_tags {
  787. display: flex;
  788. align-items: center;
  789. gap: 8px;
  790. margin-bottom: 16px;
  791. .q_no {
  792. display: inline-flex;
  793. align-items: center;
  794. justify-content: center;
  795. min-width: 24px;
  796. height: 24px;
  797. padding: 0 6px;
  798. background: #F3F3F3;
  799. border-radius: 2px;
  800. font-weight: 550;
  801. font-size: 14px;
  802. color: #333333;
  803. box-sizing: border-box;
  804. }
  805. .q_type {
  806. font-weight: 550;
  807. font-size: 14px;
  808. color: #333333;
  809. }
  810. .q_level {
  811. font-weight: 400;
  812. font-size: 14px;
  813. color: #90CB75;
  814. margin-left: 8px;
  815. }
  816. }
  817. .q_stem {
  818. font-size: 15px;
  819. line-height: 1.7;
  820. color: #333;
  821. margin-bottom: 16px;
  822. :deep(img) {
  823. max-width: 100%;
  824. }
  825. }
  826. .q_options {
  827. .q_option {
  828. display: flex;
  829. gap: 6px;
  830. margin-bottom: 10px;
  831. font-size: 14px;
  832. color: #333;
  833. line-height: 1.6;
  834. .opt_key {
  835. flex-shrink: 0;
  836. }
  837. }
  838. }
  839. }
  840. /* 作答区贴在题目卡片底部,不随题干滚动 */
  841. .answer_dock {
  842. flex-shrink: 0;
  843. display: flex;
  844. flex-direction: column;
  845. align-items: stretch;
  846. gap: 16px;
  847. padding: 0 16px 16px 16px;
  848. background: #fff;
  849. box-sizing: border-box;
  850. .dock_answer {
  851. display: flex;
  852. align-items: center;
  853. gap: 16px;
  854. min-height: 56px;
  855. padding: 10px 16px;
  856. background: #F5F7FA;
  857. border-radius: 10px;
  858. box-sizing: border-box;
  859. border: 1px solid #DCDFE6;
  860. .dock_label {
  861. flex-shrink: 0;
  862. display: inline-flex;
  863. align-items: center;
  864. height: 20px;
  865. line-height: 20px;
  866. font-size: 14px;
  867. color: #333;
  868. }
  869. .dock_sub_no {
  870. flex-shrink: 0;
  871. display: inline-flex;
  872. align-items: center;
  873. height: 20px;
  874. line-height: 20px;
  875. font-size: 14px;
  876. color: #333;
  877. }
  878. .choice_radio {
  879. display: inline-flex;
  880. align-items: center;
  881. min-height: 20px;
  882. gap: 20px;
  883. :deep(.el-checkbox) {
  884. margin-right: 0 !important;
  885. height: 20px;
  886. display: inline-flex !important;
  887. align-items: center;
  888. }
  889. :deep(.el-checkbox__input) {
  890. display: inline-flex;
  891. align-items: center;
  892. height: 16px;
  893. line-height: 16px;
  894. }
  895. :deep(.el-checkbox__inner) {
  896. width: 16px;
  897. height: 16px;
  898. border: 1px solid #DCDFE6;
  899. background: #fff;
  900. border-radius: 2px;
  901. &::after {
  902. top: 1px;
  903. left: 5px;
  904. width: 3px;
  905. height: 8px;
  906. }
  907. }
  908. :deep(.el-checkbox__input.is-focus .el-checkbox__inner) {
  909. background-color: #fff;
  910. border-color: #2E64FA;
  911. }
  912. :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
  913. background-color: #2E64FA;
  914. border-color: #2E64FA;
  915. }
  916. :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
  917. color: #333;
  918. }
  919. :deep(.el-checkbox__label) {
  920. display: inline-flex;
  921. align-items: center;
  922. height: 20px;
  923. padding-left: 8px;
  924. font-size: 14px;
  925. color: #333;
  926. line-height: 20px;
  927. }
  928. :deep(.el-radio) {
  929. margin-right: 0 !important;
  930. height: 20px;
  931. display: inline-flex !important;
  932. align-items: center;
  933. }
  934. :deep(.el-radio__input) {
  935. display: inline-flex;
  936. align-items: center;
  937. height: 16px;
  938. line-height: 16px;
  939. }
  940. :deep(.el-radio__inner) {
  941. width: 16px;
  942. height: 16px;
  943. border: 1px solid #DCDFE6;
  944. background: #fff;
  945. }
  946. :deep(.el-radio__input.is-checked .el-radio__inner) {
  947. background-color: #fff;
  948. border: 1px solid #2E64FA;
  949. box-shadow: none;
  950. }
  951. :deep(.el-radio__input.is-checked .el-radio__inner::after) {
  952. width: 10px;
  953. height: 10px;
  954. background-color: #2E64FA;
  955. transform: translate(-50%, -50%) scale(1);
  956. }
  957. :deep(.el-radio__input.is-checked + .el-radio__label),
  958. :deep(.el-radio__label) {
  959. padding-left: 8px;
  960. font-size: 14px;
  961. color: #333;
  962. line-height: 20px;
  963. }
  964. }
  965. .photo_list {
  966. display: flex;
  967. flex-wrap: wrap;
  968. gap: 10px;
  969. min-width: 0;
  970. .photo_item {
  971. position: relative;
  972. width: 64px;
  973. height: 64px;
  974. border-radius: 4px;
  975. border: 1px solid transparent;
  976. box-sizing: border-box;
  977. background: #fff;
  978. &.active {
  979. border-color: #2E64FA;
  980. }
  981. img {
  982. width: 100%;
  983. height: 100%;
  984. object-fit: cover;
  985. border-radius: 4px;
  986. display: block;
  987. cursor: zoom-in;
  988. }
  989. .photo_remove {
  990. position: absolute;
  991. top: -6px;
  992. right: -6px;
  993. width: 16px;
  994. height: 16px;
  995. border-radius: 50%;
  996. background: #C0C4CC;
  997. color: #fff;
  998. font-size: 12px;
  999. line-height: 16px;
  1000. text-align: center;
  1001. cursor: pointer;
  1002. }
  1003. }
  1004. }
  1005. }
  1006. .photo_input {
  1007. display: none;
  1008. }
  1009. .dock_actions {
  1010. display: flex;
  1011. align-items: center;
  1012. gap: 16px;
  1013. .el-button {
  1014. height: 36px;
  1015. padding: 0 14px;
  1016. border-radius: 4px;
  1017. line-height: 34px;
  1018. margin:0;
  1019. &.is-disabled,
  1020. &.is-disabled:hover,
  1021. &.is-disabled:focus {
  1022. background: #F3F3F3;
  1023. color: #C0C4CC;
  1024. border: 1px solid #C0C4CC;
  1025. }
  1026. }
  1027. .photo_btn {
  1028. :deep(span) {
  1029. display: inline-flex;
  1030. align-items: center;
  1031. gap: 4px;
  1032. }
  1033. .camera_icon {
  1034. width: 16px;
  1035. height: 16px;
  1036. display: block;
  1037. }
  1038. }
  1039. .nav_btn {
  1040. color: #2E64FA;
  1041. border-color: #2E64FA;
  1042. background: #fff;
  1043. &.is-disabled,
  1044. &.is-disabled:hover,
  1045. &.is-disabled:focus {
  1046. background: #F3F3F3;
  1047. color: #C0C4CC;
  1048. border: 1px solid #C0C4CC;
  1049. }
  1050. }
  1051. }
  1052. }
  1053. }
  1054. }
  1055. }
  1056. </style>