answer.vue 36 KB

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