TopicManager.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  1. <template>
  2. <div>
  3. <el-dialog :close-on-click-modal="false" :visible="visible" :title="'试题管理 - 试卷ID: ' + paperId" width="90%"
  4. top="5vh" @close="closeDialog" custom-class="topic-manager-dialog">
  5. <div class="topic-manager-container">
  6. <!-- 试题列表 -->
  7. <div class="topic-list-container">
  8. <div class="topic-list-header">
  9. <h3>试题列表</h3>
  10. <el-button type="primary" icon="el-icon-plus" size="small" @click="showAddForm()">
  11. 添加试题
  12. </el-button>
  13. </div>
  14. <el-table :data="topics" row-key="id" height="600" v-loading="loading" :border="true"
  15. :stripe="true">
  16. <el-table-column label="排序" width="80">
  17. <template slot-scope="{}">
  18. <i class="el-icon-sort"></i>
  19. </template>
  20. </el-table-column>
  21. <!-- <el-table-column label="ID" prop="id" width="60" /> -->
  22. <el-table-column label="题目内容" min-width="200">
  23. <template slot-scope="{ row }">
  24. <div v-if="row.show_type === 1" class="topic-content clickable"
  25. @click="showContentDialog('题目内容', row.topic_name)">
  26. {{ row.topic_name }}
  27. </div>
  28. <div v-else class="topic-content clickable"
  29. @click="showImageDialog('题目内容', row.topic_name)">
  30. <image-preview size="xs" :images="[row.topic_name]" />
  31. </div>
  32. </template>
  33. </el-table-column>
  34. <el-table-column label="题型" width="80">
  35. <template slot-scope="{ row }">
  36. <el-tag :type="getTopicTypeTag(row.topic_type)">
  37. {{ row.topic_type }}
  38. </el-tag>
  39. </template>
  40. </el-table-column>
  41. <el-table-column label="分数" prop="score" width="60" align="center" />
  42. <el-table-column label="正确答案" width="120">
  43. <template slot-scope="{ row }">
  44. <div v-if="row.show_type === 1" class="topic-content clickable"
  45. @click="showContentDialog('正确答案', row.correct_answer)">
  46. <text-ellipsis :text="row.correct_answer" :max-length="10" />
  47. </div>
  48. <div v-else class="topic-content clickable"
  49. @click="showImageDialog('正确答案', row.correct_answer)">
  50. <image-preview size="xs" :images="[row.correct_answer]" />
  51. </div>
  52. </template>
  53. </el-table-column>
  54. <el-table-column label="解析" width="120">
  55. <template slot-scope="{ row }">
  56. <div v-if="row.show_type === 1" class="topic-content clickable"
  57. @click="showContentDialog('解析', row.topic_analysis)">
  58. <text-ellipsis :text="row.topic_analysis" :max-length="10" />
  59. </div>
  60. <div v-else class="topic-content clickable"
  61. @click="showImageDialog('解析', row.topic_analysis)">
  62. <image-preview size="xs" :images="[row.topic_analysis]" />
  63. </div>
  64. </template>
  65. </el-table-column>
  66. <el-table-column label="操作" width="180" align="center" fixed="right">
  67. <template slot-scope="{ row, $index }">
  68. <el-button size="mini" type="primary" plain @click="editTopic(row)">编辑</el-button>
  69. <el-button size="mini" type="danger" plain
  70. @click="deleteTopic(row, $index)">删除</el-button>
  71. </template>
  72. </el-table-column>
  73. </el-table>
  74. <!-- 拖拽排序提示 -->
  75. <div class="drag-tips">
  76. <i class="el-icon-info"></i>
  77. 提示:可以通过拖拽行来调整试题顺序
  78. </div>
  79. </div>
  80. </div>
  81. </el-dialog>
  82. <!-- 添加/编辑试题弹窗 -->
  83. <el-dialog :visible.sync="showForm" :title="isEdit ? '编辑试题' : '添加试题'" width="800px"
  84. :close-on-click-modal="false">
  85. <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
  86. <el-form-item label="题目类型" prop="topic_type">
  87. <el-select v-model="form.topic_type" placeholder="请选择题型" @change="changeTopicType">
  88. <el-option v-for="type in topicTypes" :key="type.value" :label="type.label" :value="type.value">
  89. </el-option>
  90. </el-select>
  91. </el-form-item>
  92. <el-form-item label="显示方式" prop="show_type">
  93. <el-radio-group v-model="form.show_type">
  94. <el-radio :label="1">文本</el-radio>
  95. <el-radio :label="2">图片</el-radio>
  96. </el-radio-group>
  97. </el-form-item>
  98. <el-form-item label="题目内容" prop="topic_name" v-if="form.show_type === 1">
  99. <el-input type="textarea" :rows="3" v-model="form.topic_name" placeholder="请输入题目内容"></el-input>
  100. </el-form-item>
  101. <el-form-item label="题目图片" prop="topic_name" v-else>
  102. <uploadImage :limit="1" v-model="form.topic_name"></uploadImage>
  103. </el-form-item>
  104. <el-form-item label="分数" prop="score">
  105. <el-input-number v-model="form.score" :min="1" :max="100"></el-input-number>
  106. </el-form-item>
  107. <el-form-item label="图片选项/答案" prop="answer_type" v-if="form.show_type === 2">
  108. <el-radio-group v-model="form.answer_type">
  109. <el-radio :label="1">是</el-radio>
  110. <el-radio :label="2">否</el-radio>
  111. </el-radio-group>
  112. </el-form-item>
  113. <!-- 单选题、多选题 -->
  114. <div v-if="['单选题', '多选题'].includes(form.topic_type)">
  115. <template v-if="form.answer_type == 1">
  116. <!-- 选项:图片方式 -->
  117. <el-form-item v-for="(option, index) in options" :key="index"
  118. :label="'选项 ' + String.fromCharCode(65 + index)"
  119. :prop="'answer_' + String.fromCharCode(65 + index)">
  120. <uploadImage :limit="1" v-model="options[index]" />
  121. </el-form-item>
  122. <!-- 正确答案:选择对应选项 -->
  123. <el-form-item label="正确答案" prop="correct_answer">
  124. <el-select v-model="form.correct_answer" placeholder="请选择正确答案" multiple
  125. v-if="form.topic_type === '多选题'">
  126. <el-option v-for="(option, index) in options" :key="index"
  127. :label="'选项 ' + String.fromCharCode(65 + index)"
  128. :value="String.fromCharCode(65 + index)" />
  129. </el-select>
  130. <el-select v-model="form.correct_answer" placeholder="请选择正确答案" v-else>
  131. <el-option v-for="(option, index) in options" :key="index"
  132. :label="'选项 ' + String.fromCharCode(65 + index)"
  133. :value="String.fromCharCode(65 + index)" />
  134. </el-select>
  135. </el-form-item>
  136. </template>
  137. <template v-else>
  138. <!-- 选项:文本方式 -->
  139. <el-form-item v-for="(option, index) in options" :key="index"
  140. :label="'选项 ' + String.fromCharCode(65 + index)"
  141. :prop="'answer_' + String.fromCharCode(65 + index)">
  142. <el-input v-model="options[index]"
  143. :placeholder="'请输入选项' + String.fromCharCode(65 + index) + '的内容'" />
  144. </el-form-item>
  145. <!-- 正确答案:选择对应选项 -->
  146. <el-form-item label="正确答案" prop="correct_answer">
  147. <el-select v-model="form.correct_answer" placeholder="请选择正确答案" multiple
  148. v-if="form.topic_type === '多选题'">
  149. <el-option v-for="(option, index) in options" :key="index"
  150. :label="String.fromCharCode(65 + index)" :value="String.fromCharCode(65 + index)" />
  151. </el-select>
  152. <el-select v-model="form.correct_answer" placeholder="请选择正确答案" v-else>
  153. <el-option v-for="(option, index) in options" :key="index"
  154. :label="String.fromCharCode(65 + index)" :value="String.fromCharCode(65 + index)" />
  155. </el-select>
  156. </el-form-item>
  157. </template>
  158. </div>
  159. <div v-else-if="form.topic_type === '判断题'">
  160. <el-form-item label="正确答案" prop="correct_answer">
  161. <el-radio-group v-model="form.correct_answer">
  162. <el-radio label="正确">正确</el-radio>
  163. <el-radio label="错误">错误</el-radio>
  164. </el-radio-group>
  165. </el-form-item>
  166. </div>
  167. <!-- 填空题,简答题 -->
  168. <div v-else>
  169. <div v-if="form.answer_type === 1">
  170. <el-form-item label="正确答案" prop="correct_answer">
  171. <uploadImage :limit="1" v-model="form.correct_answer"></uploadImage>
  172. </el-form-item>
  173. </div>
  174. <div v-else>
  175. <el-form-item label="正确答案" prop="correct_answer">
  176. <el-input type="textarea" :rows="2" v-model="form.correct_answer"
  177. placeholder="请输入正确答案"></el-input>
  178. </el-form-item>
  179. </div>
  180. </div>
  181. <el-form-item label="答案解析" prop="topic_analysis" class="mt-16">
  182. <div v-if="form.show_type === 1">
  183. <el-input type="textarea" :rows="3" v-model="form.topic_analysis"
  184. placeholder="请输入题目解析"></el-input>
  185. </div>
  186. <div v-else>
  187. <uploadImage :limit="1" v-model="form.topic_analysis"></uploadImage>
  188. </div>
  189. </el-form-item>
  190. </el-form>
  191. <div slot="footer" class="dialog-footer">
  192. <el-button @click="cancelForm">取消</el-button>
  193. <el-button type="primary" @click="submitForm">保存</el-button>
  194. </div>
  195. </el-dialog>
  196. <!-- 内容查看弹窗 -->
  197. <el-dialog :visible.sync="contentDialogVisible" :title="contentDialogTitle" width="600px">
  198. <div class="content-display">
  199. <pre>{{ contentDialogContent }}</pre>
  200. </div>
  201. </el-dialog>
  202. <!-- 图片查看弹窗 -->
  203. <el-dialog :visible.sync="imageDialogVisible" :title="imageDialogTitle" width="800px">
  204. <div class="image-display">
  205. <image-preview size="lg" :images="[imageDialogImage]" />
  206. </div>
  207. </el-dialog>
  208. </div>
  209. </template>
  210. <script>
  211. import Sortable from 'sortablejs';
  212. import uploadImage from '../../../components/uploadImage'
  213. export default {
  214. components: {
  215. uploadImage
  216. },
  217. name: 'TopicManager',
  218. props: {
  219. visible: Boolean,
  220. paperId: Number,
  221. defaultSceneType: { type: Number, default: null },
  222. defaultType: { type: Number, default: null },
  223. },
  224. data() {
  225. return {
  226. loading: false,
  227. topics: [],
  228. topicTypes: [], // 题目类型列表
  229. showForm: false,
  230. isEdit: false,
  231. editingIndex: -1,
  232. form: {
  233. id: null,
  234. paper_id: null,
  235. topic_name: '',
  236. show_type: 1,
  237. answer_type: 2,
  238. topic_type: '单选题',
  239. score: 5,
  240. sort: 0,
  241. topic_analysis: '',
  242. correct_answer: '',
  243. answer_A: '',
  244. answer_B: '',
  245. answer_C: '',
  246. answer_D: '',
  247. answer_E: '',
  248. answer_F: ''
  249. },
  250. rules: {
  251. topic_name: [
  252. { required: true, message: '请输入题目内容', trigger: 'blur' }
  253. ],
  254. topic_type: [
  255. { required: true, message: '请选择题型', trigger: 'change' }
  256. ],
  257. score: [
  258. { required: true, message: '请输入分数', trigger: 'blur' }
  259. ],
  260. correct_answer: [
  261. { required: true, message: '请选择或输入正确答案', trigger: 'blur' }
  262. ]
  263. },
  264. options: ['', '', '', '', '', ''],
  265. currentTopic: null,
  266. // 内容查看弹窗
  267. contentDialogVisible: false,
  268. contentDialogTitle: '',
  269. contentDialogContent: '',
  270. // 图片查看弹窗
  271. imageDialogVisible: false,
  272. imageDialogTitle: '',
  273. imageDialogImage: ''
  274. };
  275. },
  276. mounted() {
  277. this.loadTopicTypes();
  278. },
  279. watch: {
  280. visible(val) {
  281. if (val) {
  282. this.loadTopics();
  283. } else {
  284. this.resetForm();
  285. }
  286. },
  287. },
  288. methods: {
  289. async loadTopicTypes() {
  290. try {
  291. const res = await this.$http.get('/topics/topicTypes');
  292. if (res.data.code === 0) {
  293. this.topicTypes = res.data.data;
  294. } else {
  295. this.$message.error(res.data.msg);
  296. }
  297. } catch (error) {
  298. this.$message.error('加载题目类型失败');
  299. console.error(error);
  300. }
  301. },
  302. async loadTopics() {
  303. this.loading = true;
  304. try {
  305. const res = await this.$http.get('/topics/index', {
  306. params: { paper_id: this.paperId, limit: 999 }
  307. });
  308. if (res.data.code === 0) {
  309. this.topics = res.data.data;
  310. this.initSortable();
  311. } else {
  312. this.$message.error(res.data.msg);
  313. }
  314. } catch (error) {
  315. this.$message.error('加载试题失败');
  316. console.error(error);
  317. } finally {
  318. this.loading = false;
  319. }
  320. },
  321. initSortable() {
  322. const table = document.querySelector('.topic-list-container .el-table__body-wrapper tbody');
  323. if (table) {
  324. Sortable.create(table, {
  325. animation: 150,
  326. handle: '.el-icon-sort',
  327. onEnd: async (evt) => {
  328. const { oldIndex, newIndex } = evt;
  329. const movedItem = this.topics.splice(oldIndex, 1)[0];
  330. this.topics.splice(newIndex, 0, movedItem);
  331. // 生成排序数据:倒序(index 大的排前面)
  332. const sortData = this.topics.map((item, index) => ({
  333. id: item.id,
  334. sort: this.topics.length - index
  335. }));
  336. try {
  337. const res = await this.$http.post('/topics/sort', {
  338. paper_id: this.paperId,
  339. sort_data: sortData
  340. });
  341. if (res.data.code === 0) {
  342. this.$message.success('排序已保存');
  343. } else {
  344. this.$message.error(res.data.msg);
  345. }
  346. } catch (error) {
  347. this.$message.error('保存排序失败');
  348. console.error(error);
  349. }
  350. }
  351. });
  352. }
  353. },
  354. showAddForm() {
  355. this.isEdit = false;
  356. this.resetForm();
  357. this.form.paper_id = this.paperId;
  358. this.showForm = true;
  359. },
  360. editTopic(topic) {
  361. this.isEdit = true;
  362. this.currentTopic = { ...topic };
  363. // 先处理判断题答案,再整体赋值
  364. let correctAnswer = topic.correct_answer;
  365. // 专门处理判断题答案
  366. if (topic.topic_type === '判断题') {
  367. correctAnswer = this.formatJudgmentAnswer(topic.correct_answer);
  368. console.log('判断题答案处理:', topic.correct_answer, '->', correctAnswer);
  369. } else if (topic.topic_type === '多选题' && typeof topic.correct_answer === 'string') {
  370. correctAnswer = topic.correct_answer.split(',');
  371. }
  372. // 整体赋值form
  373. this.form = {
  374. ...topic,
  375. correct_answer: correctAnswer
  376. };
  377. this.editingIndex = this.topics.findIndex(item => item.id === topic.id);
  378. // 设置选项
  379. this.options = [
  380. topic.answer_A || '',
  381. topic.answer_B || '',
  382. topic.answer_C || '',
  383. topic.answer_D || '',
  384. topic.answer_E || '',
  385. topic.answer_F || '',
  386. ];
  387. console.log('编辑后form数据:', this.form);
  388. console.log('correct_answer值:', this.form.correct_answer);
  389. this.showForm = true;
  390. },
  391. // 添加判断题答案格式化方法
  392. formatJudgmentAnswer(value) {
  393. console.log('格式化判断题答案,输入值:', value, '类型:', typeof value);
  394. if (value === '正确' || value === '错误') {
  395. return value; // 已经是正确格式,直接返回
  396. }
  397. // 处理各种可能的数据格式
  398. const mapping = {
  399. '正确': '正确',
  400. '错误': '错误',
  401. 'true': '正确',
  402. 'false': '错误',
  403. '1': '正确',
  404. '0': '错误',
  405. '对的': '正确',
  406. '错的': '错误',
  407. '对': '正确',
  408. '错': '错误'
  409. };
  410. const result = mapping[value] || '正确'; // 默认值
  411. console.log('格式化结果:', result);
  412. return result;
  413. },
  414. async deleteTopic(topic, index) {
  415. try {
  416. await this.$confirm('确定要删除这道试题吗?', '提示', {
  417. type: 'warning'
  418. });
  419. const res = await this.$http.post('/topics/delete', {
  420. id: topic.id
  421. });
  422. if (res.data.code === 0) {
  423. this.$message.success('删除成功');
  424. this.topics.splice(index, 1);
  425. } else {
  426. this.$message.error(res.data.msg);
  427. }
  428. } catch (error) {
  429. if (error !== 'cancel') {
  430. this.$message.error('删除失败');
  431. console.error(error);
  432. }
  433. }
  434. },
  435. changeTopicType(value) {
  436. console.log(value)
  437. if (this.form.topic_type === '多选题') {
  438. this.form.correct_answer = []
  439. } else {
  440. this.form.correct_answer = ''
  441. }
  442. this.form.topic_type = value
  443. },
  444. async submitForm() {
  445. this.$refs.formRef.validate(async (valid) => {
  446. if (!valid) return;
  447. try {
  448. // 保存选项
  449. ['A', 'B', 'C', 'D', 'E', 'F'].forEach((letter, index) => {
  450. this.form[`answer_${letter}`] = this.options[index] || '';
  451. });
  452. // 处理多选题答案
  453. let correctAnswer = this.form.correct_answer;
  454. if (this.form.topic_type === '多选题' && Array.isArray(correctAnswer)) {
  455. correctAnswer = correctAnswer.join(',');
  456. }
  457. const formData = {
  458. ...this.form,
  459. correct_answer: correctAnswer,
  460. paper_id: this.paperId
  461. };
  462. const res = await this.$http.post("/topics/edit", formData);
  463. if (res.data.code === 0) {
  464. this.$message.success(this.isEdit ? '更新成功' : '添加成功');
  465. this.loadTopics();
  466. this.cancelForm();
  467. this.$emit('saved');
  468. } else {
  469. this.$message.error(res.data.msg);
  470. }
  471. } catch (error) {
  472. this.$message.error(this.isEdit ? '更新失败' : '添加失败');
  473. console.error(error);
  474. }
  475. });
  476. },
  477. cancelForm() {
  478. this.showForm = false;
  479. this.resetForm();
  480. },
  481. resetForm() {
  482. this.form = {
  483. id: null,
  484. paper_id: this.paperId,
  485. topic_name: '',
  486. show_type: 1,
  487. answer_type: 2,
  488. topic_type: '单选题',
  489. score: 5,
  490. sort: 0,
  491. topic_analysis: '',
  492. correct_answer: '',
  493. answer_A: '',
  494. answer_B: '',
  495. answer_C: '',
  496. answer_D: '',
  497. answer_E: '',
  498. answer_F: ''
  499. };
  500. this.options = ['', '', '', '', '', ''];
  501. this.editingIndex = -1;
  502. if (this.$refs.formRef) {
  503. this.$refs.formRef.clearValidate();
  504. }
  505. },
  506. handleImageSuccess(res) {
  507. if (res.code === 0) {
  508. this.form.topic_name = res.data.url;
  509. } else {
  510. this.$message.error(res.msg);
  511. }
  512. },
  513. beforeImageUpload(file) {
  514. const isJPGOrPNG = file.type === 'image/jpeg' || file.type === 'image/png';
  515. const isLt2M = file.size / 1024 / 1024 < 2;
  516. if (!isJPGOrPNG) {
  517. this.$message.error('上传图片只能是 JPG/PNG 格式!');
  518. }
  519. if (!isLt2M) {
  520. this.$message.error('上传图片大小不能超过 2MB!');
  521. }
  522. return isJPGOrPNG && isLt2M;
  523. },
  524. getTopicTypeTag(type) {
  525. const typeMap = {
  526. '单选题': 'primary',
  527. '多选题': 'success',
  528. '判断题': 'warning',
  529. '填空题': 'info',
  530. '简答题': 'danger'
  531. };
  532. return typeMap[type] || 'default';
  533. },
  534. // 显示文本内容弹窗
  535. showContentDialog(title, content) {
  536. this.contentDialogTitle = title;
  537. this.contentDialogContent = content;
  538. this.contentDialogVisible = true;
  539. },
  540. // 显示图片内容弹窗
  541. showImageDialog(title, image) {
  542. this.imageDialogTitle = title;
  543. this.imageDialogImage = image;
  544. this.imageDialogVisible = true;
  545. },
  546. closeDialog() {
  547. this.$emit('update:visible', false);
  548. }
  549. }
  550. };
  551. </script>
  552. <style scoped>
  553. .topic-manager-container {
  554. height: 70vh;
  555. }
  556. .topic-list-container {
  557. width: 100%;
  558. }
  559. .topic-list-header {
  560. display: flex;
  561. justify-content: space-between;
  562. align-items: center;
  563. margin-bottom: 15px;
  564. }
  565. .topic-content {
  566. max-height: 60px;
  567. overflow: hidden;
  568. text-overflow: ellipsis;
  569. display: -webkit-box;
  570. -webkit-line-clamp: 3;
  571. -webkit-box-orient: vertical;
  572. }
  573. .topic-content.clickable {
  574. cursor: pointer;
  575. transition: all 0.3s ease;
  576. }
  577. .topic-content.clickable:hover {
  578. background-color: #f5f7fa;
  579. border-radius: 4px;
  580. padding: 4px;
  581. }
  582. .content-display {
  583. max-height: 400px;
  584. overflow-y: auto;
  585. }
  586. .content-display pre {
  587. white-space: pre-wrap;
  588. word-wrap: break-word;
  589. font-family: inherit;
  590. margin: 0;
  591. padding: 10px;
  592. background-color: #f8f9fa;
  593. border-radius: 4px;
  594. }
  595. .image-display {
  596. text-align: center;
  597. }
  598. .drag-tips {
  599. margin-top: 10px;
  600. padding: 8px 12px;
  601. background-color: #f4f4f5;
  602. color: #909399;
  603. border-radius: 4px;
  604. font-size: 12px;
  605. }
  606. </style>