ExcelImport.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. <template>
  2. <el-dialog :visible.sync="visible" :before-close="handleClose" custom-class="excel-dialog"
  3. :close-on-click-modal="false" :fullscreen="isFullScreen">
  4. <template #title>
  5. <span style="cursor: move;">导入 Excel</span>
  6. <el-button type="text" @click="toggleFullScreen" style="float: right; margin-right: 40px;">
  7. {{ isFullScreen ? '退出全屏' : '全屏' }}
  8. </el-button>
  9. </template>
  10. <!-- 顶部试卷信息表单 -->
  11. <el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px" class="mb-10">
  12. <el-row :gutter="15">
  13. <el-col :sm="12">
  14. <el-form-item label="题目名称" prop="name">
  15. <el-input v-model="formData.name" placeholder="请输入关键词" />
  16. </el-form-item>
  17. <el-form-item label="题目分类" prop="subject_id">
  18. <el-select v-model="formData.subject_id" placeholder="请选择题目分类">
  19. <el-option v-for="item in subjectOptionsComputed" :key="item.id" :label="item.name"
  20. :value="item.id" />
  21. </el-select>
  22. </el-form-item>
  23. <el-form-item label="总分" prop="score_total">
  24. <el-input-number v-model="formData.score_total" :min="0" :max="1000" />
  25. </el-form-item>
  26. </el-col>
  27. <el-col :sm="12">
  28. <el-form-item label="题目数量" prop="topic_count">
  29. <el-input-number v-model="formData.topic_count" :min="0" :max="5000" />
  30. </el-form-item>
  31. <el-form-item label="是否收费" prop="is_charge">
  32. <el-radio-group v-model="formData.is_charge">
  33. <el-radio :label="0">免费</el-radio>
  34. <el-radio :label="1">收费</el-radio>
  35. </el-radio-group>
  36. </el-form-item>
  37. <el-form-item label="状态" prop="status">
  38. <el-switch v-model="formData.status" :active-value="1" :inactive-value="2" />
  39. </el-form-item>
  40. <el-form-item label="排序" prop="sort">
  41. <el-input-number v-model="formData.sort" :min="0" />
  42. </el-form-item>
  43. </el-col>
  44. </el-row>
  45. <el-form-item label="备注" class="mt-16">
  46. <el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="请输入备注" />
  47. </el-form-item>
  48. </el-form>
  49. <!-- 操作按钮区域 -->
  50. <div class="import-actions">
  51. <el-button type="success" icon="el-icon-download" @click="downloadTemplate">
  52. 下载导入模板
  53. </el-button>
  54. <el-upload :before-upload="beforeUpload" :show-file-list="false" :on-change="handleFileChange"
  55. accept=".xls,.xlsx" style="display: inline-block; margin-left: 10px;">
  56. <el-button type="primary" icon="el-icon-upload2">选择 Excel 文件</el-button>
  57. </el-upload>
  58. </div>
  59. <!-- 格式说明 -->
  60. <div class="format-tips">
  61. <el-alert title="Excel格式要求" type="info" :closable="false" show-icon>
  62. <template slot="default">
  63. <p><strong>Excel格式要求:</strong></p>
  64. CREATE TABLE `lev_ad` (
  65. `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  66. `title` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '广告标题',
  67. `position` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '广告位置:1-主页轮播图,2-会员中心占位图',
  68. `cover` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '广告图片',
  69. `type` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '广告访问:1-站内链接,2-站外链接,3-其他小程序,4-微信视频号',
  70. `description` varchar(150) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '广告描述',
  71. `url` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '广告链接/小程序链接/视频号链接',
  72. `link_type` tinyint(1) DEFAULT '1' COMMENT '跳转类型:1-默认,2-小程序',
  73. `mp_appid` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '跳转小程序APPID',
  74. `width` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '广告宽度',
  75. `height` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '广告高度',
  76. `start_time` int(10) unsigned NOT NULL COMMENT '开始时间',
  77. `end_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '结束时间',
  78. `view_num` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '广告点击次数',
  79. `status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '状态:1在用 2停用',
  80. `sort` smallint(5) unsigned NOT NULL DEFAULT '125' COMMENT '排序',
  81. `create_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '添加时间',
  82. `update_time` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间',
  83. `mark` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '有效标识(1正常 0删除)',
  84. PRIMARY KEY (`id`) USING BTREE,
  85. KEY `title` (`title`) USING BTREE,
  86. KEY `ad_sort_id` (`position`) USING BTREE
  87. ) ENGINE=InnoDB AUTO_INCREMENT=48 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  88. ROW_FORMAT=DYNAMIC COMMENT='广告管理表'; <p>• 第一行必须是标题行,支持以下列名:题目类型、题目、选项A~F(或答案A~F)、正确答案、解析、分数</p>
  89. <p>• 列顺序可以任意排列,系统会自动识别各列</p>
  90. <p>• 题目类型:单选题、多选题、判断题、填空题、问答题、写作题、应用题、计算题、证明题、解答题、论述题</p>
  91. <p>• 正确答案:单选题填选项字母(A/B/C/D/E/F),多选题用逗号分隔(A,B),判断题填对/错,填空题填答案,问答题可留空</p>
  92. <p>• 分数:每道题的分值,建议单选题2-5分,多选题5-10分,判断题1-2分,填空题3-8分,问答题10-20分</p>
  93. <p>• 支持富文本格式</p>
  94. <p><strong>示例标题行:</strong>题目类型、题目、解析、正确答案、答案A、答案B、答案C、答案D、分数、答案E、答案F</p>
  95. </template>
  96. </el-alert>
  97. </div>
  98. <!-- Excel 预览 -->
  99. <div v-if="previewData.length" class="excel-preview">
  100. <el-table :data="previewData" row-key="_id" border style="width: 100%" :height="tableHeight"
  101. :max-height="tableMaxHeight">
  102. <!-- 拖动列 -->
  103. <!-- <el-table-column label="拖动" width="60">
  104. <template #default="{ $index }">
  105. <i class="el-icon-rank drag-handle" @mousedown.prevent.stop="startDrag($index)"></i>
  106. </template>
  107. </el-table-column> -->
  108. <el-table-column prop="topic_type" label="题目类型" width="120" />
  109. <el-table-column label="题目" min-width="200">
  110. <template #default="{ row }">
  111. <text-ellipsis :text="row.topic_name" :max-length="20" />
  112. </template>
  113. </el-table-column>
  114. <el-table-column label="解析" min-width="150">
  115. <template #default="{ row }">
  116. <text-ellipsis :text="row.topic_analysis" :max-length="20" />
  117. </template>
  118. </el-table-column>
  119. <el-table-column label="正确答案" min-width="100">
  120. <template #default="{ row }">
  121. <text-ellipsis :text="row.correct_answer" :max-length="20" />
  122. </template>
  123. </el-table-column>
  124. <el-table-column label="答案A" min-width="100">
  125. <template #default="{ row }">
  126. <text-ellipsis :text="row.answer_A" :max-length="20" />
  127. </template>
  128. </el-table-column>
  129. <el-table-column label="答案B" min-width="100">
  130. <template #default="{ row }">
  131. <text-ellipsis :text="row.answer_B" :max-length="20" />
  132. </template>
  133. </el-table-column>
  134. <el-table-column label="答案C" min-width="100">
  135. <template #default="{ row }">
  136. <text-ellipsis :text="row.answer_C" :max-length="20" />
  137. </template>
  138. </el-table-column>
  139. <el-table-column label="答案D" min-width="100">
  140. <template #default="{ row }">
  141. <text-ellipsis :text="row.answer_D" :max-length="20" />
  142. </template>
  143. </el-table-column>
  144. <el-table-column label="答案E" min-width="100">
  145. <template #default="{ row }">
  146. <text-ellipsis :text="row.answer_E" :max-length="20" />
  147. </template>
  148. </el-table-column>
  149. <el-table-column label="答案F" min-width="100">
  150. <template #default="{ row }">
  151. <text-ellipsis :text="row.answer_F" :max-length="20" />
  152. </template>
  153. </el-table-column>
  154. <el-table-column prop="score" label="分数" width="80" />
  155. </el-table>
  156. </div>
  157. <!-- 底部按钮 -->
  158. <span slot="footer" class="dialog-footer">
  159. <el-button @click="handleReset">重置</el-button>
  160. <el-button @click="handleClose">取消</el-button>
  161. <el-button type="primary" :disabled="!previewData.length" @click="handleConfirm">确认导入</el-button>
  162. </span>
  163. </el-dialog>
  164. </template>
  165. <script>
  166. import * as XLSX from "xlsx";
  167. import TextEllipsis from "@/components/TextEllipsis.vue";
  168. export default {
  169. name: "ExcelImport",
  170. components: { TextEllipsis },
  171. props: {
  172. visible: { type: Boolean, default: false },
  173. defaultSceneType: { type: Number, default: null },
  174. defaultType: { type: Number, default: null },
  175. subjectOptions: { type: Array, default: () => [] },
  176. },
  177. data() {
  178. return {
  179. file: null,
  180. previewData: [],
  181. isFullScreen: false,
  182. tableHeight: 400,
  183. formData: this.getDefaultFormData(),
  184. formRules: {
  185. name: [{ required: true, message: "请输入题目名称", trigger: "blur" }],
  186. subject_id: [{ required: true, message: "请选择题目分类", trigger: "change" }],
  187. score_total: [{ required: true, message: "请输入总分", trigger: "blur" }],
  188. topic_count: [{ required: true, message: "请输入题目数量", trigger: "blur" }],
  189. is_charge: [{ required: true, message: "请选择是否收费", trigger: "change" }]
  190. }
  191. };
  192. },
  193. computed: {
  194. tableMaxHeight() { return this.isFullScreen ? window.innerHeight - 250 : 400; },
  195. subjectOptionsComputed() { return this.subjectOptions.length ? this.subjectOptions : []; }
  196. },
  197. methods: {
  198. // 下载导入模板
  199. downloadTemplate() {
  200. // 创建模板数据
  201. const templateData = [
  202. ['题目类型', '题目', '选项A', '选项B', '选项C', '选项D', '选项E', '选项F', '正确答案', '解析', '分数'],
  203. ['单选题', '这是一道单选题示例?', '选项A内容', '选项B内容', '选项C内容', '选项D内容', '', '', 'A', '这是单选题的解析', '5'],
  204. ['多选题', '这是一道多选题示例?', '选项A内容', '选项B内容', '选项C内容', '选项D内容', '', '', 'A,B', '这是多选题的解析', '10'],
  205. ['判断题', '这是一道判断题示例?', '', '', '', '', '', '', '对', '这是判断题的解析', '2'],
  206. ['填空题', '这是一道填空题示例,答案是___', '', '', '', '', '', '', '答案内容', '这是填空题的解析', '8'],
  207. ['问答题', '这是一道问答题示例?', '', '', '', '', '', '', '', '这是问答题的解析', '15'],
  208. ['写作题', '这是一道写作题示例?', '', '', '', '', '', '', '', '这是写作题的解析', '20'],
  209. ['应用题', '这是一道应用题示例?', '', '', '', '', '', '', '', '这是应用题的解析', '25'],
  210. ['计算题', '这是一道计算题示例?', '', '', '', '', '', '', '', '这是计算题的解析', '30'],
  211. ['证明题', '这是一道证明题示例?', '', '', '', '', '', '', '', '这是证明题的解析', '35'],
  212. ['解答题', '这是一道解答题示例?', '', '', '', '', '', '', '', '这是解答题的解析', '40'],
  213. ['论述题', '这是一道论述题示例?', '', '', '', '', '', '', '', '这是论述题的解析', '45']
  214. ];
  215. // 创建工作簿
  216. const wb = XLSX.utils.book_new();
  217. const ws = XLSX.utils.aoa_to_sheet(templateData);
  218. // 设置列宽
  219. ws['!cols'] = [
  220. { width: 12 }, // 题目类型列
  221. { width: 40 }, // 题目列
  222. { width: 20 }, // 选项A列
  223. { width: 20 }, // 选项B列
  224. { width: 20 }, // 选项C列
  225. { width: 20 }, // 选项D列
  226. { width: 20 }, // 选项E列
  227. { width: 20 }, // 选项F列
  228. { width: 12 }, // 正确答案列
  229. { width: 30 }, // 解析列
  230. { width: 8 } // 分数列
  231. ];
  232. // 添加工作表到工作簿
  233. XLSX.utils.book_append_sheet(wb, ws, '题目导入模板');
  234. // 下载文件
  235. const fileName = `题目导入模板_${new Date().toISOString().slice(0, 10)}.xlsx`;
  236. XLSX.writeFile(wb, fileName);
  237. this.$message.success('模板下载成功');
  238. },
  239. getDefaultFormData() {
  240. return {
  241. type: this.type,
  242. name: "",
  243. scene_type: this.defaultSceneType,
  244. subject_id: null,
  245. score_total: 0,
  246. topic_count: 0,
  247. is_charge: 0,
  248. status: 1,
  249. sort: 0,
  250. remark: ""
  251. };
  252. },
  253. handleClose() {
  254. this.clearAll();
  255. this.$emit("update:visible", false);
  256. },
  257. handleReset() { this.clearAll(); },
  258. clearAll() {
  259. this.file = null;
  260. this.previewData = [];
  261. this.formData = this.getDefaultFormData();
  262. this.isFullScreen = false;
  263. },
  264. beforeUpload(file) {
  265. const isExcel = file.type.includes("excel") || file.name.endsWith(".xls") || file.name.endsWith(".xlsx");
  266. if (!isExcel) this.$message.error("只能上传 Excel 文件");
  267. return isExcel;
  268. },
  269. handleFileChange(file) {
  270. this.file = file.raw;
  271. this.readExcel(this.file);
  272. },
  273. readExcel(file) {
  274. const reader = new FileReader();
  275. reader.onload = e => {
  276. const data = e.target.result;
  277. const workbook = XLSX.read(data, { type: "binary" });
  278. const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
  279. const allData = XLSX.utils.sheet_to_json(firstSheet, { header: 1, defval: "" });
  280. // 从文件名提取题目名称(去除扩展名)
  281. const fileName = file.name;
  282. const nameWithoutExt = fileName.replace(/\.[^/.]+$/, "");
  283. this.formData.name = this.formData.name || nameWithoutExt;
  284. if (allData.length < 2) return this.$message.error("Excel 数据行数不足");
  285. // 读取第一行作为标题,动态匹配列索引
  286. const headers = allData[0];
  287. const getColumnIndex = (searchKeywords) => {
  288. const index = headers.findIndex(header =>
  289. searchKeywords.some(keyword =>
  290. String(header).trim().includes(keyword)
  291. )
  292. );
  293. return index >= 0 ? index : -1;
  294. };
  295. // 动态获取列索引
  296. const colIndices = {
  297. topic_type: getColumnIndex(['题目类型', '题型']),
  298. topic_name: getColumnIndex(['题目', '题目内容']),
  299. answer_A: getColumnIndex(['选项A', '答案A', '选项 A', '答案 A']),
  300. answer_B: getColumnIndex(['选项B', '答案B', '选项 B', '答案 B']),
  301. answer_C: getColumnIndex(['选项C', '答案C', '选项 C', '答案 C']),
  302. answer_D: getColumnIndex(['选项D', '答案D', '选项 D', '答案 D']),
  303. answer_E: getColumnIndex(['选项E', '答案E', '选项 E', '答案 E']),
  304. answer_F: getColumnIndex(['选项F', '答案F', '选项 F', '答案 F']),
  305. correct_answer: getColumnIndex(['正确答案', '标准答案', '答案']),
  306. topic_analysis: getColumnIndex(['解析', '题目解析', '答案解析']),
  307. score: getColumnIndex(['分数', '分值', '得分'])
  308. };
  309. // 验证必要的列是否存在
  310. const requiredColumns = ['topic_type', 'topic_name', 'correct_answer'];
  311. const missingColumns = requiredColumns.filter(key => colIndices[key] === -1);
  312. if (missingColumns.length > 0) {
  313. return this.$message.error(`缺少必要的列:${missingColumns.join('、')}`);
  314. }
  315. const rows = allData.slice(1).filter(r => r.some(c => c !== ""));
  316. this.previewData = rows.map((r, index) => ({
  317. _id: index,
  318. topic_type: colIndices.topic_type >= 0 ? (r[colIndices.topic_type] || "") : "",
  319. topic_name: colIndices.topic_name >= 0 ? (r[colIndices.topic_name] || "") : "",
  320. answer_A: colIndices.answer_A >= 0 ? (r[colIndices.answer_A] || "") : "",
  321. answer_B: colIndices.answer_B >= 0 ? (r[colIndices.answer_B] || "") : "",
  322. answer_C: colIndices.answer_C >= 0 ? (r[colIndices.answer_C] || "") : "",
  323. answer_D: colIndices.answer_D >= 0 ? (r[colIndices.answer_D] || "") : "",
  324. answer_E: colIndices.answer_E >= 0 ? (r[colIndices.answer_E] || "") : "",
  325. answer_F: colIndices.answer_F >= 0 ? (r[colIndices.answer_F] || "") : "",
  326. correct_answer: colIndices.correct_answer >= 0 ? (r[colIndices.correct_answer] || "") : "",
  327. topic_analysis: colIndices.topic_analysis >= 0 ? (r[colIndices.topic_analysis] || "") : "",
  328. score: colIndices.score >= 0 ? (Number(r[colIndices.score]) || 0) : 0,
  329. show_type: 1, // 导入的数据固定为文本类型
  330. answer_type: 2, // 导入的数据固定为不可提交图片答案
  331. topic_image_id: "", // 默认空值
  332. sort: index + 1, // 按顺序排序
  333. status: 1, // 默认有效状态
  334. mark: 1 // 默认未删除
  335. }));
  336. this.formData.topic_count = this.previewData.length;
  337. this.formData.score_total = this.previewData.reduce((sum, item) => sum + item.score, 0);
  338. };
  339. reader.readAsBinaryString(file);
  340. },
  341. async handleConfirm() {
  342. // 验证表单
  343. const valid = await this.$refs.formRef.validate().catch(() => false);
  344. if (!valid) {
  345. this.$message.error("请完善必填信息");
  346. return;
  347. }
  348. const res = await this.$http.post(`/papers/import`, {
  349. formData: { ...this.formData, type: this.defaultType, scene_type: this.defaultSceneType },
  350. topicsData: this.previewData
  351. });
  352. if (res.data.code === 0) {
  353. this.$message.success("导入成功");
  354. this.$emit("imported", this.formData, this.previewData);
  355. this.handleClose();
  356. } else {
  357. this.$message.error(res.data.msg);
  358. }
  359. },
  360. toggleFullScreen() { this.isFullScreen = !this.isFullScreen; }
  361. }
  362. };
  363. </script>
  364. <style scoped>
  365. .excel-dialog {
  366. width: 1200px !important;
  367. min-width: 600px;
  368. resize: both;
  369. overflow: auto;
  370. }
  371. .import-actions {
  372. margin: 20px 0;
  373. text-align: center;
  374. display: flex;
  375. justify-content: center;
  376. align-items: center;
  377. gap: 10px;
  378. }
  379. .format-tips {
  380. margin: 20px 0;
  381. }
  382. .excel-preview {
  383. overflow-x: auto;
  384. }
  385. .el-table th {
  386. position: sticky;
  387. top: 0;
  388. background: #fff;
  389. z-index: 10;
  390. }
  391. .el-table td {
  392. white-space: nowrap;
  393. text-overflow: ellipsis;
  394. overflow: hidden;
  395. }
  396. .drag-handle {
  397. cursor: move;
  398. }
  399. </style>