index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. <template>
  2. <div v-loading="loading" class="answer">
  3. <header class="header">
  4. <div class="back round" @click="goBack">
  5. <i class="el-icon-arrow-left"></i>
  6. <span>返回</span>
  7. </div>
  8. <div class="question-info">
  9. <div class="round">
  10. <SvgIcon icon-class="list" />
  11. <span>{{ curQuestionIndex + 1 }} / {{ questionList.length }}</span>
  12. </div>
  13. <div v-if="!isTeacherAnnotations" class="round primary">
  14. <SvgIcon icon-class="hourglass" />{{ secondFormatConversion(time) }}
  15. </div>
  16. </div>
  17. </header>
  18. <main class="main">
  19. <StartQuestion
  20. v-if="
  21. curQuestionIndex === -1 && !(user_answer_record_info.is_exist_answer_record === 'true' && answer_mode === 2)
  22. "
  23. :question-length="questionList.length"
  24. :answer-time-limit-minute="answer_time_limit_minute"
  25. @startAnswer="startAnswer"
  26. />
  27. <AnswerReport v-else-if="isSubmit" :record-report="recordReport" @selectQuestion="selectQuestion" />
  28. <template v-for="({ id }, i) in questionList" v-else>
  29. <component
  30. :is="curQuestionPage"
  31. v-if="i === curQuestionIndex"
  32. :key="id"
  33. ref="exercise"
  34. :data="currentQuestion"
  35. />
  36. </template>
  37. </main>
  38. <footer class="footer">
  39. <el-popover v-model="isPopover" placement="top-start" trigger="click">
  40. <div v-if="isEnable(remark.is_remarked) && !isTeacher" class="remark-container">
  41. <div class="remark-info">
  42. <el-avatar :size="24" :src="remark.remark_person_image_url" />
  43. <span class="remark-name">{{ remark.remark_person_name }}</span>
  44. <span class="remark-time">{{ remark.remark_time }}</span>
  45. </div>
  46. <div class="remark">
  47. {{ remark.remark }}
  48. </div>
  49. <div class="file">
  50. <el-image v-for="{ file_url, file_id } in remark.file_list" :key="file_id" :src="file_url" fit="contain" />
  51. </div>
  52. </div>
  53. <div v-else class="annotations-container">
  54. <div class="title">增加批注</div>
  55. <div class="score">
  56. <span>分数</span>
  57. <el-input-number v-model="remark.score" :min="0" :step="1" />
  58. </div>
  59. <el-input v-model="remark.remark" type="textarea" rows="6" resize="none" class="remark" />
  60. <div>图片/视频</div>
  61. <el-upload action="no" accept="video/*,image/*" :show-file-list="false" :http-request="upload">
  62. <div class="upload">
  63. <i class="el-icon-plus avatar-uploader-icon"></i>
  64. <span>Upload</span>
  65. </div>
  66. </el-upload>
  67. <ul class="file-list">
  68. <li v-for="({ file_name, file_id }, i) in remark.file_list" :key="file_id" @click="removeFile(i)">
  69. <span>{{ file_name }}</span>
  70. <SvgIcon icon-class="delete" />
  71. </li>
  72. </ul>
  73. <div class="popover-footer">
  74. <el-button @click="isPopover = false">取消</el-button>
  75. <el-button type="primary" @click="fillQuestionAnswerRemark">确定</el-button>
  76. </div>
  77. </div>
  78. <div
  79. v-show="isAnnotations"
  80. slot="reference"
  81. :class="['annotations', { has: isEnable(remark.is_remarked) && !isTeacher }]"
  82. >
  83. <template v-if="isEnable(remark.is_remarked) && !isTeacher">
  84. <span>有一条教师批注</span>
  85. </template>
  86. <template v-else><i class="el-icon-plus"></i><span>批注</span></template>
  87. </div>
  88. </el-popover>
  89. <div>
  90. <template
  91. v-if="
  92. curQuestionIndex === -1 && !(user_answer_record_info.is_exist_answer_record === 'true' && answer_mode === 2)
  93. "
  94. >
  95. <el-button type="primary" round @click="startAnswer">开始答题</el-button>
  96. </template>
  97. <template v-else-if="isSubmit">
  98. <el-button v-if="answer_mode === 1" round type="primary" @click="startAnswer">开始答题</el-button>
  99. </template>
  100. <template v-else>
  101. <el-button round @click="fillQuestionAnswer('pre')">上一题</el-button>
  102. <el-button
  103. v-if="curQuestionIndex === questionList.length - 1 && !isTeacherAnnotations"
  104. type="primary"
  105. round
  106. @click="submitAnswer"
  107. >
  108. 提交
  109. </el-button>
  110. <el-button
  111. v-else-if="curQuestionIndex < questionList.length - 1 || !isTeacherAnnotations"
  112. type="primary"
  113. round
  114. @click="fillQuestionAnswer('next')"
  115. >下一题</el-button
  116. >
  117. </template>
  118. </div>
  119. </footer>
  120. </div>
  121. </template>
  122. <script>
  123. import { secondFormatConversion } from '@/utils/transform';
  124. import {
  125. GetExerciseQuestionIndexList,
  126. GetShareRecordInfo,
  127. StartAnswer,
  128. FillQuestionAnswer,
  129. SubmitAnswer,
  130. GetQuestionInfo_AnswerRecord,
  131. FillQuestionAnswerRemark,
  132. GetAnswerRecordReport,
  133. } from '@/api/exercise';
  134. import { subjectiveQuestionList } from './answer';
  135. import { fileUpload } from '@/api/app';
  136. import StartQuestion from './components/StartQuestion.vue';
  137. import AnswerReport from './components/AnswerReport.vue';
  138. import PreviewQuestionTypeMixin from '../data/PreviewQuestionTypeMixin';
  139. export default {
  140. name: 'AnswerPage',
  141. components: {
  142. StartQuestion,
  143. AnswerReport,
  144. },
  145. mixins: [PreviewQuestionTypeMixin],
  146. data() {
  147. const { id, share_record_id, answer_record_id, exercise_id, question_index } = this.$route.query;
  148. let questionIndex = Number(question_index);
  149. return {
  150. exercise_id: id || exercise_id, // 练习题id
  151. share_record_id, // 分享记录id
  152. answer_record_id: answer_record_id ?? '', // 答题记录id
  153. secondFormatConversion,
  154. isTeacher: this.$store.getters.isTeacher, // 是否是教师
  155. user_answer_record_info: {
  156. correct_answer_show_mode: 1, // 正确答案显示模式
  157. }, // 当前用户的答题记录信息
  158. // 问题列表
  159. questionList: [],
  160. // 当前问题
  161. currentQuestion: {},
  162. // 当前问题索引
  163. curQuestionIndex: -1,
  164. question_index: questionIndex >= 0 ? questionIndex : -1, // 跳转的问题索引
  165. loading: false,
  166. // 倒计时
  167. countDownTimer: null,
  168. answer_mode: 1, // 答题模式
  169. answer_time_limit_minute: 30, // 答题时间限制
  170. time: 1800,
  171. isSubmit: false,
  172. isView: false, // 练习模式下是否查看
  173. curQuestionPage: '', // 当前问题页面
  174. remark: {
  175. is_remarked: 'false',
  176. score: 0,
  177. remark: '',
  178. remark_person_image_url: '',
  179. remark_person_name: '',
  180. remark_time: '',
  181. file_list: [],
  182. }, // 批注
  183. isPopover: false,
  184. recordReport: {
  185. answer_record: {
  186. answer_duration: 0,
  187. right_count: 0,
  188. error_count: 0,
  189. is_remarked: 'false',
  190. },
  191. question_list: [],
  192. }, // 答题报告
  193. };
  194. },
  195. computed: {
  196. // 是否教师批改
  197. isTeacherAnnotations() {
  198. return this.question_index >= 0 && this.isTeacher;
  199. },
  200. // 是否显示批注
  201. isAnnotations() {
  202. return this.remark.is_remarked === 'true' || this.isTeacherAnnotations;
  203. },
  204. // 是否考试模式
  205. isExamMode() {
  206. return this.answer_mode === 2;
  207. },
  208. },
  209. watch: {
  210. curQuestionIndex() {
  211. this.getQuestionInfo_AnswerRecord();
  212. },
  213. isSubmit(val) {
  214. if (val) {
  215. this.getAnswerRecordReport();
  216. }
  217. },
  218. },
  219. created() {
  220. this.init();
  221. },
  222. beforeDestroy() {
  223. if (this.countDownTimer) clearInterval(this.countDownTimer);
  224. },
  225. methods: {
  226. // 初始化
  227. init() {
  228. if (this.exercise_id) {
  229. this.getExerciseQuestionIndexList();
  230. }
  231. if (this.share_record_id && !this.exercise_id) {
  232. this.loading = true;
  233. GetShareRecordInfo({ share_record_id: this.share_record_id }).then(
  234. ({ user_answer_record_info, share_record: { exercise_id, answer_mode, answer_time_limit_minute } }) => {
  235. this.user_answer_record_info = user_answer_record_info;
  236. this.exercise_id = exercise_id;
  237. this.getExerciseQuestionIndexList();
  238. this.answer_time_limit_minute = answer_time_limit_minute;
  239. this.time = answer_time_limit_minute * 60;
  240. this.loading = false;
  241. // 如果是考试模式,且已经存在答题记录,则直接显示答题报告
  242. if (this.user_answer_record_info.is_exist_answer_record === 'true' && !this.isTeacher) {
  243. this.answer_record_id = this.user_answer_record_info.answer_record_id;
  244. this.answer_mode = answer_mode;
  245. // 如果是考试模式,且已经存在答题记录,则直接显示答题报告
  246. if (this.isExamMode) this.isSubmit = true;
  247. }
  248. if (!this.isTeacher) {
  249. this.getAnswerRecordReport();
  250. }
  251. },
  252. );
  253. }
  254. },
  255. // 获取答题报告
  256. getAnswerRecordReport() {
  257. if (!this.answer_record_id) return;
  258. GetAnswerRecordReport({ answer_record_id: this.answer_record_id })
  259. .then(({ answer_record, question_list }) => {
  260. if (answer_record.is_remarked === 'true') {
  261. this.isSubmit = true;
  262. this.curQuestionIndex = 0;
  263. }
  264. this.recordReport = {
  265. answer_record,
  266. question_list,
  267. };
  268. })
  269. .catch(() => {});
  270. },
  271. getExerciseQuestionIndexList() {
  272. GetExerciseQuestionIndexList({ exercise_id: this.exercise_id }).then(({ index_list }) => {
  273. this.questionList = index_list.map((item) => ({
  274. ...item,
  275. isFill: this.isTeacherAnnotations,
  276. }));
  277. if (this.question_index >= 0) {
  278. this.curQuestionIndex = this.question_index;
  279. }
  280. });
  281. },
  282. goBack() {
  283. if (this.isView) {
  284. this.isSubmit = true;
  285. this.isView = false;
  286. return;
  287. }
  288. this.$router.push('/personal_question');
  289. },
  290. // 倒计时
  291. countDown() {
  292. this.countDownTimer = setInterval(() => {
  293. this.time -= 1;
  294. if (this.time === 0) {
  295. clearInterval(this.countDownTimer);
  296. }
  297. }, 1000);
  298. },
  299. startAnswer() {
  300. if (!this.share_record_id) {
  301. this.curQuestionIndex = 0;
  302. return;
  303. }
  304. StartAnswer({ exercise_id: this.exercise_id, share_record_id: this.share_record_id }).then(
  305. ({ answer_mode, answer_record_id }) => {
  306. this.answer_record_id = answer_record_id;
  307. this.countDown();
  308. this.answer_mode = answer_mode;
  309. this.curQuestionIndex = 0;
  310. this.isSubmit = false;
  311. },
  312. );
  313. },
  314. preQuestion() {
  315. if (this.curQuestionIndex === 0) return;
  316. this.curQuestionIndex -= 1;
  317. },
  318. nextQuestion() {
  319. if (this.curQuestionIndex === this.questionList.length - 1) return;
  320. this.curQuestionIndex += 1;
  321. },
  322. /**
  323. * 填写答案
  324. * @param {'pre' | 'next'} type 上一题/下一题
  325. */
  326. fillQuestionAnswer(type) {
  327. if (type === 'pre' && this.curQuestionIndex <= 0) return;
  328. if (type === 'next' && this.curQuestionIndex > this.questionList.length - 1) return;
  329. if (!this.answer_record_id) {
  330. this.curQuestionIndex = type === 'pre' ? this.curQuestionIndex - 1 : this.curQuestionIndex + 1;
  331. return;
  332. }
  333. // 如果已填写,直接跳转
  334. if (this.questionList[this.curQuestionIndex].isFill) {
  335. if (type === 'pre') return this.preQuestion();
  336. if (type === 'next') return this.nextQuestion();
  337. }
  338. return FillQuestionAnswer({
  339. answer_record_id: this.answer_record_id,
  340. question_id: this.questionList[this.curQuestionIndex].id,
  341. answer: JSON.stringify(this.$refs.exercise[0].answer),
  342. }).then(() => {
  343. this.questionList[this.curQuestionIndex].isFill = true;
  344. if (subjectiveQuestionList.includes(this.currentQuestion.type) || this.isExamMode) {
  345. if (type === 'pre') return this.preQuestion();
  346. if (type === 'next') return this.nextQuestion();
  347. }
  348. // 显示答案
  349. this.$refs.exercise[0].showAnswer(
  350. this.answer_mode === 1,
  351. this.user_answer_record_info.correct_answer_show_mode === 1,
  352. );
  353. });
  354. },
  355. // 得到答题记录题目信息
  356. getQuestionInfo_AnswerRecord() {
  357. GetQuestionInfo_AnswerRecord({
  358. answer_record_id: this.answer_record_id,
  359. question_id: this.questionList[this.curQuestionIndex].id,
  360. }).then(({ question, user_answer: { is_fill_answer, content }, remark }) => {
  361. // 批注
  362. this.remark = remark;
  363. // 题目内容
  364. if (question.content) {
  365. this.currentQuestion = JSON.parse(question.content);
  366. this.curQuestionPage =
  367. this.questionList.length === 0 || this.curQuestionIndex < 0
  368. ? ''
  369. : this.previewComponents[this.questionList[this.curQuestionIndex].type];
  370. }
  371. // 如果已经填写过答案,直接显示答案
  372. if (is_fill_answer === 'true') {
  373. this.$nextTick().then(() => {
  374. this.$refs.exercise?.[0].showAnswer(
  375. this.answer_mode === 1 && !this.isTeacherAnnotations,
  376. this.user_answer_record_info.correct_answer_show_mode === 1 && !this.isTeacherAnnotations,
  377. content.length > 0 ? JSON.parse(content) : null,
  378. );
  379. });
  380. }
  381. });
  382. },
  383. // 提交答题
  384. submitAnswer() {
  385. if (!this.answer_record_id) return;
  386. this.$confirm('是否确认提交答题?', '提示', {
  387. confirmButtonText: '确定',
  388. cancelButtonText: '取消',
  389. type: 'warning',
  390. })
  391. .then(() => {
  392. clearInterval(this.countDownTimer);
  393. // 如果已经填写过答案,直接提交
  394. if (this.questionList[this.curQuestionIndex].isFill) {
  395. SubmitAnswer({ answer_record_id: this.answer_record_id })
  396. .then(() => {
  397. this.isSubmit = true;
  398. })
  399. .catch(() => {});
  400. return;
  401. }
  402. this.fillQuestionAnswer('next').then(() => {
  403. SubmitAnswer({ answer_record_id: this.answer_record_id })
  404. .then(() => {
  405. this.isSubmit = true;
  406. })
  407. .catch(() => {});
  408. });
  409. })
  410. .catch(() => {});
  411. },
  412. selectQuestion(i) {
  413. this.isSubmit = false;
  414. this.isView = true;
  415. this.curQuestionIndex = i;
  416. },
  417. upload(file) {
  418. fileUpload('Mid', file).then(({ file_info_list }) => {
  419. if (file_info_list.length > 0) {
  420. const { file_id, file_url, file_name } = file_info_list[0];
  421. this.remark.file_list.push({ file_id, file_url, file_name });
  422. }
  423. });
  424. },
  425. removeFile(i) {
  426. this.remark.file_list.splice(i, 1);
  427. },
  428. // 填写批注
  429. fillQuestionAnswerRemark() {
  430. FillQuestionAnswerRemark({
  431. answer_record_id: this.answer_record_id,
  432. question_id: this.questionList[this.curQuestionIndex].id,
  433. file_id_list: this.remark.file_list.map(({ file_id }) => file_id),
  434. score: this.remark.score,
  435. remark: this.remark.remark,
  436. }).then(() => {
  437. this.$message.success('批注成功');
  438. this.isPopover = false;
  439. });
  440. },
  441. },
  442. };
  443. </script>
  444. <style lang="scss" scoped>
  445. .answer {
  446. display: flex;
  447. flex-direction: column;
  448. row-gap: 16px;
  449. height: 100%;
  450. padding: 16px;
  451. background-color: #fff;
  452. border-radius: 24px;
  453. box-shadow: 0 6px 30px 5px #0000000d;
  454. .header {
  455. display: flex;
  456. align-items: center;
  457. justify-content: space-between;
  458. height: 38px;
  459. font-size: 14px;
  460. .back {
  461. cursor: pointer;
  462. }
  463. .question-info {
  464. display: flex;
  465. column-gap: 12px;
  466. }
  467. }
  468. .main {
  469. flex: 1;
  470. }
  471. .footer {
  472. position: relative;
  473. display: flex;
  474. justify-content: center;
  475. .annotations {
  476. position: absolute;
  477. left: 0;
  478. display: flex;
  479. column-gap: 8px;
  480. align-items: center;
  481. padding: 7px 16px;
  482. font-size: 14px;
  483. cursor: pointer;
  484. background-color: $fill-color;
  485. border-radius: 20px;
  486. &.has {
  487. color: $danger-color;
  488. background-color: #ffece8;
  489. }
  490. }
  491. .el-button {
  492. padding: 9px 40px;
  493. }
  494. }
  495. }
  496. </style>
  497. <style lang="scss">
  498. .el-popover {
  499. display: flex;
  500. flex-direction: column;
  501. row-gap: 8px;
  502. padding: 8px 8px 14px;
  503. font-size: 14px;
  504. .remark-container {
  505. display: flex;
  506. flex-direction: column;
  507. row-gap: 8px;
  508. .remark-info {
  509. display: flex;
  510. column-gap: 8px;
  511. align-items: center;
  512. .remark-name {
  513. flex: 1;
  514. }
  515. .remark-time {
  516. font-size: 12px;
  517. color: #999;
  518. }
  519. }
  520. .file {
  521. display: flex;
  522. flex-wrap: wrap;
  523. gap: 8px;
  524. .el-image {
  525. width: 80px;
  526. height: 80px;
  527. background-color: #d9d9d9;
  528. }
  529. }
  530. }
  531. .annotations-container {
  532. display: flex;
  533. flex-direction: column;
  534. row-gap: 8px;
  535. .title {
  536. color: #000;
  537. }
  538. .score {
  539. display: flex;
  540. column-gap: 8px;
  541. align-items: center;
  542. :first-child {
  543. color: #999;
  544. }
  545. }
  546. .remark {
  547. width: 350px;
  548. }
  549. .upload {
  550. display: flex;
  551. flex-direction: column;
  552. align-items: center;
  553. justify-content: space-around;
  554. width: 80px;
  555. height: 80px;
  556. padding: 8px;
  557. background-color: $fill-color;
  558. border: 1px solid $border-color;
  559. }
  560. .file-list {
  561. display: flex;
  562. flex-direction: column;
  563. row-gap: 4px;
  564. > li {
  565. display: flex;
  566. column-gap: 4px;
  567. :first-child {
  568. flex: 1;
  569. }
  570. :last-child {
  571. cursor: pointer;
  572. }
  573. }
  574. }
  575. .popover-footer {
  576. display: flex;
  577. justify-content: flex-end;
  578. }
  579. }
  580. }
  581. </style>