index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. <template>
  2. <div class="exercise">
  3. <div class="list">
  4. <el-button icon="el-icon-back" @click="back">返回练习管理</el-button>
  5. <div class="list-title">
  6. <el-input
  7. v-if="isEditExercise"
  8. v-model="exercise.name"
  9. @keyup.enter.native="UpdateExerciseName"
  10. @blur="UpdateExerciseName"
  11. />
  12. <span v-else class="nowrap-ellipsis">{{ exercise.name }}</span>
  13. <SvgIcon icon-class="edit" class-name="edit" @click="editExercise" />
  14. </div>
  15. <div class="exercise-list">
  16. <ul>
  17. <draggable v-model="index_list" animation="300" @end="moveQuestion" @start="handleStart">
  18. <transition-group>
  19. <li v-for="(item, i) in index_list" :key="i" :class="['exercise-item', { active: i === curIndex }]">
  20. <SvgIcon icon-class="child" :size="20" />
  21. <span class="item-name nowrap-ellipsis" @click="selectExerciseItem(i)">
  22. {{ i + 1 }}. {{ exerciseNames[item.type] }}
  23. </span>
  24. <SvgIcon icon-class="copy" class="pointer" :size="10" @click="copy(i)" />
  25. </li>
  26. </transition-group>
  27. </draggable>
  28. </ul>
  29. </div>
  30. <div class="list-operate">
  31. <el-button type="primary" @click="oneClickImport">一键导入</el-button>
  32. <el-button type="primary" @click="showSelectQuestionType">新建</el-button>
  33. </div>
  34. </div>
  35. <CreateMain
  36. ref="createMain"
  37. :cur-index="curIndex"
  38. :index-list="index_list"
  39. :loading="loading"
  40. @selectExerciseItem="selectExerciseItem"
  41. @setPreview="setPreview"
  42. @deleteQuestion="deleteQuestion"
  43. />
  44. <div class="preview" :style="{ height: preview ? 'calc(50vh - 32px)' : 'auto' }">
  45. <div class="preview-header">
  46. <span class="quick-preview">快捷预览:</span>
  47. <div class="preview-right">
  48. <template v-if="preview">
  49. <span class="preview-button plain" @click="refreshPreviewData">
  50. <SvgIcon icon-class="loop" size="14" /><span>刷新</span>
  51. </span>
  52. <span class="preview-button" @click="fullPreview">
  53. <SvgIcon icon-class="eye" /> <span>完整预览</span>
  54. </span>
  55. <span class="preview-button plain" @click="setPreview"><SvgIcon icon-class="close" />关闭预览</span>
  56. </template>
  57. <template v-else>
  58. <span class="preview-button" @click="setPreview"><SvgIcon icon-class="eye" />预览</span>
  59. </template>
  60. </div>
  61. </div>
  62. <div class="preview-content">
  63. <component :is="curPreviewPage" v-if="preview" :data="previewData" :child-preview-data="childPreviewData" />
  64. </div>
  65. </div>
  66. <SelectQuestionType :visible.sync="visible" @addQuestionToExercise="addQuestionToExercise" />
  67. <ImportPage :visible.sync="importVisible" @oneKeyImport="oneKeyImport" />
  68. </div>
  69. </template>
  70. <script>
  71. import { exerciseNames, questionDataList, analysisOneKeyImportData } from '../data/questionType';
  72. import {
  73. AddQuestionToExercise,
  74. DeleteQuestion,
  75. GetExerciseQuestionIndexList,
  76. GetQuestionInfo,
  77. GetExerciseInfo,
  78. UpdateExercise,
  79. MoveQuestion,
  80. } from '@/api/exercise';
  81. import CreateMain from './components/create.vue';
  82. import PreviewQuestionTypeMixin from '../data/PreviewQuestionTypeMixin';
  83. import ImportPage from './components/common/ImportPage.vue';
  84. import SelectQuestionType from './components/common/SelectQuestionType.vue';
  85. import draggable from 'vuedraggable';
  86. export default {
  87. name: 'CreateExercise',
  88. components: {
  89. CreateMain,
  90. SelectQuestionType,
  91. draggable,
  92. ImportPage,
  93. },
  94. mixins: [PreviewQuestionTypeMixin],
  95. provide() {
  96. return {
  97. updateCurQuestionType: this.updateCurQuestionType,
  98. exercise_id: this.exercise_id,
  99. refreshPreviewData: this.refreshPreviewData,
  100. updateLoading: (loading) => {
  101. this.loading = loading;
  102. },
  103. };
  104. },
  105. data() {
  106. const { id, back_url } = this.$route.query;
  107. return {
  108. loading: false, // 题目加载中
  109. exercise_id: id, // 练习id
  110. exercise: { name: '' }, // 练习信息
  111. isEditExercise: false, // 是否编辑练习
  112. moveQuestionData: {}, // 移动题目数据
  113. isMoveQuestion: false, // 是否移动题目
  114. curIndex: 0, // 当前练习索引
  115. index_list: [], // 练习列表
  116. exerciseNames, // 练习名称
  117. preview: false, // 预览显示
  118. previewData: {}, // 预览数据
  119. back_url: back_url || '/personal_question', // 返回地址
  120. visible: false, // 选择题目类型弹窗
  121. importVisible: false, // 一键导入弹窗
  122. };
  123. },
  124. computed: {
  125. curPreviewPage() {
  126. if (this.index_list.length === 0) return '';
  127. return this.previewComponents[this.index_list[this.curIndex].type];
  128. },
  129. },
  130. watch: {
  131. curIndex() {
  132. if (this.index_list.length === 0 || this.isMoveQuestion) return;
  133. this.getQuestionInfo();
  134. },
  135. },
  136. created() {
  137. this.getExerciseQuestionIndexList(true);
  138. this.getExerciseInfo();
  139. },
  140. methods: {
  141. // 返回练习管理
  142. back() {
  143. this.$router.push(this.back_url);
  144. },
  145. /**
  146. * 添加题目到练习
  147. * @param {string} type 题目类型
  148. * @param {string} additional_type 附加类型
  149. * @param {string} content 题目内容
  150. */
  151. addQuestionToExercise(type, additional_type, content = '') {
  152. this.$refs.createMain.saveQuestion();
  153. AddQuestionToExercise({
  154. exercise_id: this.exercise_id,
  155. type,
  156. additional_type,
  157. content,
  158. })
  159. .then(() => {
  160. this.getExerciseQuestionIndexList(false, true);
  161. })
  162. .catch(() => {
  163. this.$message.error('添加失败');
  164. });
  165. },
  166. showSelectQuestionType() {
  167. this.visible = true;
  168. },
  169. oneClickImport() {
  170. this.importVisible = true;
  171. },
  172. async oneKeyImport(text) {
  173. let dataList = await analysisOneKeyImportData(text.split(/\n\s*\n+/));
  174. this.importVisible = false;
  175. const loading = this.$loading({
  176. text: '正在导入题目,请稍等...',
  177. });
  178. // 一键导入题目,按 dataList 顺序添加题目,等待第一个题目添加成功后,再添加第二个题目
  179. dataList
  180. .reduce((promise, { type, additional_type, content }) => {
  181. return promise.then(() => {
  182. return AddQuestionToExercise({
  183. exercise_id: this.exercise_id,
  184. type,
  185. additional_type,
  186. content,
  187. });
  188. });
  189. }, Promise.resolve())
  190. .then(() => {
  191. this.getExerciseQuestionIndexList(false, true);
  192. loading.close();
  193. });
  194. },
  195. // 创建默认题目
  196. createDefaultQuestion() {
  197. this.addQuestionToExercise('select', 'single', questionDataList['select']);
  198. },
  199. /**
  200. * 获取练习题目索引列表
  201. * @param {boolean} init 是否初始化
  202. * @param {boolean} isAdd 是否添加题目
  203. */
  204. getExerciseQuestionIndexList(init = false, isAdd = false) {
  205. GetExerciseQuestionIndexList({ exercise_id: this.exercise_id })
  206. .then(({ index_list }) => {
  207. this.index_list = index_list;
  208. if (isAdd) {
  209. this.curIndex = this.index_list.length - 1;
  210. }
  211. if (!init) return;
  212. this.getQuestionInfo();
  213. })
  214. .catch((err) => {
  215. console.log(err);
  216. });
  217. },
  218. getExerciseInfo() {
  219. GetExerciseInfo({ exercise_id: this.exercise_id }).then(({ exercise }) => {
  220. this.exercise = exercise;
  221. });
  222. },
  223. editExercise() {
  224. if (this.isEditExercise) {
  225. return this.UpdateExerciseName();
  226. }
  227. this.isEditExercise = !this.isEditExercise;
  228. },
  229. UpdateExerciseName() {
  230. UpdateExercise(this.exercise)
  231. .then(() => {
  232. this.isEditExercise = false;
  233. })
  234. .catch(() => {
  235. this.$message.error('修改失败');
  236. });
  237. },
  238. /**
  239. * 获取题目信息
  240. */
  241. getQuestionInfo() {
  242. if (this.index_list.length === 0) return;
  243. this.$refs.createMain.clearSaveDate();
  244. this.loading = true;
  245. let curId = this.index_list[this.curIndex].id;
  246. GetQuestionInfo({ question_id: this.index_list[this.curIndex].id })
  247. .then(({ question, file_list }) => {
  248. if (curId !== this.index_list[this.curIndex].id) return;
  249. this.$refs.createMain.resetSaveDate();
  250. if (!question.content) return;
  251. // 将题目文件id列表添加到题目内容中
  252. let file_id_list = file_list.map(({ file_id }) => file_id);
  253. let content = JSON.parse(question.content);
  254. content.file_id_list = file_id_list;
  255. this.$nextTick(() => {
  256. this.$refs.createMain.$refs.exercise?.[0].setQuestion(content);
  257. this.refreshPreviewData();
  258. });
  259. })
  260. .catch(() => {});
  261. },
  262. /**
  263. * 复制练习
  264. * @param {Number} index 练习索引
  265. */
  266. copy(index) {
  267. this.$confirm('是否复制当前题目', '提示', {
  268. confirmButtonText: '确定',
  269. cancelButtonText: '取消',
  270. type: 'warning',
  271. })
  272. .then(() => {
  273. GetQuestionInfo({ question_id: this.index_list[index].id }).then(
  274. ({ question: { type, additional_type, content } }) => {
  275. this.addQuestionToExercise(type, additional_type, content);
  276. },
  277. );
  278. })
  279. .catch(() => {});
  280. },
  281. /**
  282. * 选择练习
  283. * @param {number} index 练习索引
  284. */
  285. selectExerciseItem(index) {
  286. if (index < 0 || index > this.index_list.length - 1) return;
  287. this.$refs.createMain.clearSaveDate();
  288. this.$refs.createMain.saveQuestion();
  289. this.curIndex = index;
  290. },
  291. // 刷新预览数据
  292. refreshPreviewData() {
  293. this.previewData = this.$refs.createMain.$refs.exercise?.[0].data || {};
  294. this.childPreviewData = this.$refs.createMain.$refs.exercise?.[0].childPreviewData || [];
  295. },
  296. // 完整预览
  297. fullPreview() {
  298. window.open(
  299. this.$router.resolve({
  300. path: '/answer',
  301. query: {
  302. id: this.exercise_id,
  303. type: 'show',
  304. question_index: this.curIndex,
  305. back_url: 'not-return',
  306. },
  307. }).href,
  308. '_blank',
  309. );
  310. },
  311. // 预览
  312. setPreview() {
  313. this.preview = !this.preview;
  314. this.refreshPreviewData();
  315. },
  316. // 删除练习
  317. deleteQuestion() {
  318. DeleteQuestion({ question_id: this.index_list[this.curIndex].id })
  319. .then(() => {
  320. let index = this.curIndex - 1;
  321. this.curIndex = Math.max(0, index);
  322. // 当删除的题目是第一题时,需要重新获取题目信息,因为 curIndex 没有变化
  323. this.getExerciseQuestionIndexList(index < 0);
  324. this.$message.success('删除成功');
  325. })
  326. .catch(() => {
  327. this.$message.error('删除失败');
  328. });
  329. },
  330. /**
  331. * 修改当前题目类型
  332. * @param {array} arr
  333. */
  334. updateCurQuestionType(arr) {
  335. let type = arr[arr.length - 1];
  336. this.index_list[this.curIndex].type = type;
  337. },
  338. /**
  339. * 移动题目
  340. * @param {object} param 移动参数
  341. * @param {number} param.newIndex 移动后的索引
  342. * @param {number} param.oldIndex 移动前的索引
  343. */
  344. moveQuestion({ newIndex, oldIndex }) {
  345. if (newIndex === oldIndex) {
  346. this.isMoveQuestion = false;
  347. this.moveQuestionData = {};
  348. return;
  349. }
  350. let isMoveCur = newIndex === this.curIndex || oldIndex === this.curIndex; // 是否移动当前题目
  351. let isEffectCurIndex = newIndex < this.curIndex !== oldIndex < this.curIndex; // 是否影响当前题目索引
  352. if (isMoveCur) {
  353. this.curIndex = this.curIndex === newIndex ? oldIndex : newIndex;
  354. } else if (isEffectCurIndex) {
  355. this.curIndex = newIndex < this.curIndex ? this.curIndex + 1 : this.curIndex - 1;
  356. }
  357. this.isMoveQuestion = false;
  358. if (isMoveCur || isEffectCurIndex) {
  359. this.$nextTick(() => {
  360. this.$refs.createMain.$refs.exercise?.[0].setQuestion(this.moveQuestionData);
  361. this.moveQuestionData = {};
  362. this.refreshPreviewData();
  363. this.$refs.createMain.resetSaveDate();
  364. });
  365. }
  366. let order = newIndex > oldIndex;
  367. MoveQuestion({
  368. question_id: this.index_list[newIndex].id,
  369. dest_question_id: this.index_list[order ? newIndex - 1 : newIndex + 1].id,
  370. dest_position: order ? 1 : 0,
  371. })
  372. .then(() => {
  373. this.$message.success('移动成功');
  374. let curI = this.curIndex;
  375. // 移动题目后,单独更新当前题目的题号
  376. GetQuestionInfo({ question_id: this.index_list[this.curIndex].id })
  377. .then(({ question }) => {
  378. if (!question) return;
  379. if (curI !== this.curIndex) return;
  380. this.$refs.createMain.$refs.exercise?.[0].setQuestionNumber(question.question_number);
  381. })
  382. .catch(() => {});
  383. })
  384. .catch(() => {
  385. this.$message.error('移动失败');
  386. });
  387. },
  388. /**
  389. * 开始移动题目
  390. * @param {object} param 移动参数
  391. * @param {number} param.newIndex 移动后的索引
  392. * @param {number} param.oldIndex 移动前的索引
  393. */
  394. handleStart({ newIndex, oldIndex }) {
  395. this.$refs.createMain.clearSaveDate();
  396. let isMoveCur = newIndex === this.curIndex || oldIndex === this.curIndex; // 是否移动当前题目
  397. let isEffectCurIndex = newIndex < this.curIndex !== oldIndex < this.curIndex; // 是否影响当前题目索引
  398. if (isMoveCur || isEffectCurIndex) {
  399. this.isMoveQuestion = true;
  400. this.moveQuestionData = this.$refs.createMain.$refs.exercise?.[0].data;
  401. }
  402. },
  403. },
  404. };
  405. </script>
  406. <style lang="scss" scoped>
  407. .exercise {
  408. display: grid;
  409. grid-template: 1fr auto / 224px minmax(800px, 1fr);
  410. height: 100%;
  411. .list {
  412. display: flex;
  413. flex-direction: column;
  414. grid-area: 1 / 1 / -1;
  415. row-gap: 16px;
  416. padding: 16px 8px;
  417. background-color: #fff;
  418. border-right: 1px solid $border-color;
  419. &-title {
  420. display: flex;
  421. column-gap: 8px;
  422. align-items: center;
  423. font-weight: bold;
  424. :first-child {
  425. flex: 1;
  426. }
  427. .edit {
  428. cursor: pointer;
  429. }
  430. }
  431. .exercise-list {
  432. max-height: calc(100% - 130px);
  433. overflow-y: auto;
  434. .exercise-item {
  435. display: flex;
  436. column-gap: 8px;
  437. align-items: center;
  438. padding: 4px 8px;
  439. color: #333;
  440. cursor: pointer;
  441. border-radius: 2px;
  442. &.active {
  443. color: $main-color;
  444. background-color: #f4f8ff;
  445. }
  446. .item-name {
  447. flex: 1;
  448. }
  449. }
  450. }
  451. &-operate {
  452. display: flex;
  453. > .el-button {
  454. flex: 1;
  455. }
  456. }
  457. }
  458. .preview {
  459. position: relative;
  460. padding: 16px 24px;
  461. overflow: auto;
  462. background-color: #eff1f5;
  463. border-top: 1px solid $border-color;
  464. .preview-header {
  465. display: flex;
  466. justify-content: space-between;
  467. width: 100%;
  468. .quick-preview {
  469. font-weight: bold;
  470. }
  471. .preview-right {
  472. display: flex;
  473. column-gap: 18px;
  474. align-items: center;
  475. .preview-button {
  476. display: flex;
  477. column-gap: 8px;
  478. align-items: center;
  479. color: #175dff;
  480. cursor: pointer;
  481. &.plain {
  482. color: #2f3742;
  483. }
  484. }
  485. }
  486. }
  487. &-content {
  488. width: 100%;
  489. height: calc(100% - 24px);
  490. overflow: auto;
  491. }
  492. }
  493. }
  494. </style>
  495. <style lang="scss">
  496. .el-cascader-menu__wrap {
  497. height: 235px;
  498. }
  499. </style>