answer.vue 31 KB

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