question.vue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153
  1. <template>
  2. <div class="detail_main">
  3. <div class="question_header">
  4. <div class="item_title">
  5. <div class="item_step">1</div>
  6. 扫描标准答案:<el-button type="primary" @click="GotoScan()" :loading="scanLoading" style="width: 88px;">{{scanText}}</el-button>
  7. <span class="preview_btn" @click="OpenImage()">预览</span>
  8. <!-- <el-button style="margin-left: 10px;width: 116px;">引用试题结构</el-button> -->
  9. </div>
  10. <div class="choose_template">
  11. 当前模板:{{currentTemplateName}}
  12. <!-- <el-select v-model="queryParams.start" placeholder="请选择" class="select_width" >
  13. <el-option :label="number" :value="number" v-for="number in templateQuestionCount"></el-option>
  14. </el-select> -->
  15. <span class="choose_btn" @click="OpenChooseTemplate()">更换</span>
  16. </div>
  17. </div>
  18. <div class="page_jg_20"></div>
  19. <div class="page_module">
  20. <div class="item_title">
  21. <div class="item_step">2</div>
  22. 设置试题结构:
  23. <div class="item_search">
  24. <div class="search_name">题目从</div>
  25. <el-select v-model="queryParams.start" placeholder="请输入" filterable default-first-option class="select_width" >
  26. <el-option :label="number" :value="number" v-for="number in questionRange"></el-option>
  27. </el-select>
  28. <div class="search_name">至</div>
  29. <el-select v-model="queryParams.end" placeholder="请输入" filterable default-first-option class="select_width" >
  30. <el-option :label="number" :value="number" v-for="number in questionRange"></el-option>
  31. </el-select>
  32. </div>
  33. <div class="item_search">
  34. <div class="search_name">题目类型:</div>
  35. <el-select v-model="queryParams.questionType" placeholder="请选择" class="select_width" >
  36. <el-option label="单选题" :value="1"></el-option>
  37. <el-option label="多选题" :value="2"></el-option>
  38. </el-select>
  39. </div>
  40. <div class="item_search">
  41. <div class="search_name">每题满分:</div>
  42. <el-input v-model="queryParams.fullMark" placeholder="请输入" maxlength="3" @input="(val: any) => onScoreInput(queryParams, 'fullMark', val)" class="select_width" ></el-input>
  43. </div>
  44. <div class="item_search">
  45. <div class="search_name">分组:</div>
  46. <el-input v-model="queryParams.groupName" placeholder="请输入" class="select_width" ></el-input>
  47. </div>
  48. <div class="item_search" v-if="queryParams.questionType == 2" >
  49. (给分规则:少选得<el-input v-model="shaoxuan" style="width: 50px;" maxlength="4" @change="ScoreYanzheng()"></el-input>分
  50. )
  51. </div>
  52. <div class="item_search">
  53. <el-button type="primary" style="width: 68px;" @click="EnterSetQuestion()">确定</el-button>
  54. </div>
  55. <div class="delete_all">
  56. <div class="delete_btn" @click="DeleteAll()">全部删除</div>
  57. </div>
  58. </div>
  59. <div class="page_jg_16"></div>
  60. <div class="table_42">
  61. <el-table :data="tableData" v-loading="tableLoading" element-loading-text="加载中……" element-loading-spinner="el-icon-loading" element-loading-background="#ffffff" style="width: 100%" :height="tableHeight">
  62. <el-table-column prop="questionName" label="题目名称" width="80">
  63. </el-table-column>
  64. <!-- 题目类型 questionType 1 单校 2多选 -->
  65. <el-table-column prop="questionType" label="题目类型" width="120">
  66. <template v-slot="scope">
  67. <el-select v-model="scope.row.questionType" style="width: calc(100% - 32px);" @change="TypeChange(scope.row)">
  68. <el-option :value="1" label="单选题"></el-option>
  69. <el-option :value="2" label="多选题"></el-option>
  70. </el-select>
  71. </template>
  72. </el-table-column>
  73. <el-table-column prop="date" label="标注答案" width="442">
  74. <template v-slot="scope">
  75. <div class="answer_input">
  76. <!-- 单选题 -->
  77. <div class="single_selected" v-if="scope.row.questionType == 1">
  78. <el-button v-for="(item,index) in scope.row.optionsSize" :type="scope.row.standardAnswer === answerList[index] ? 'primary': ''" class="selected_button" @click="SingleClick(scope.row,answerList[index])">{{answerList[index]}}</el-button>
  79. </div>
  80. <!-- 多选 -->
  81. <div class="single_selected" v-if="scope.row.questionType == 2">
  82. <el-button v-for="(item,index) in scope.row.optionsSize" :type="scope.row.standardAnswer?.includes(answerList[index]) ? 'primary': ''" class="selected_button" @click="MultiClick(answerList[index],scope.row)">{{answerList[index]}}</el-button>
  83. </div>
  84. <!-- 判断题 -->
  85. <div class="single_selected" v-if="scope.row.questionType == 3">
  86. <el-button v-for="(item,index) in 2" :type="scope.row.standardAnswer === tflist[index] ? 'primary': ''" class="selected_button" @click="SingleClick(tflist[index],scope.row)">{{tflist[index]}}</el-button>
  87. </div>
  88. </div>
  89. </template>
  90. </el-table-column>
  91. <el-table-column prop="fullScore" label="满分" width="90">
  92. <template v-slot="scope">
  93. <div class="full_mark_input">
  94. <el-input v-model="scope.row.fullScore" maxlength="3" @input="(val: any) => onScoreInput(scope.row, 'fullScore', val)"></el-input>
  95. </div>
  96. </template>
  97. </el-table-column>
  98. <el-table-column prop="name" label="多选题给分分布">
  99. <template v-slot="scope">
  100. <div v-if="scope.row.questionType == '1' || scope.row.questionType == '3'">
  101. {{ '-' }}
  102. </div>
  103. <div v-if="scope.row.questionType == '2'">
  104. <div v-if="scope.row.multipleRule == '1'">
  105. 全对得满分
  106. <span>,少选得{{ scope.row.multipleRuleDesc || 0 }}分,选错不得分</span>
  107. </div>
  108. <div v-if="scope.row.multipleRule == '2'">
  109. 全对得满分,选对1个得{{ scope.row.multipleRuleDesc?.split(',')[0] }}分
  110. <span v-if="scope.row.multipleRuleDesc && scope.row.standardAnswer.split(',').length > 2">,选对2个得{{ scope.row.multipleRuleDesc?.split(',')[1] }}分</span>
  111. <span v-if="scope.row.multipleRuleDesc && scope.row.standardAnswer.split(',').length == 4">,选对3个得{{ scope.row.multipleRuleDesc?.split(',')[2] }}分</span>
  112. </div>
  113. <div v-if="scope.row.multipleRule == '3'">
  114. {{scope.row.multipleRuleDesc}}
  115. </div>
  116. </div>
  117. </template>
  118. </el-table-column>
  119. <el-table-column prop="groupName" label="分组">
  120. <template v-slot="scope">
  121. <div class="input_width">
  122. <el-input v-model="scope.row.groupName" maxlength="3" ></el-input>
  123. </div>
  124. </template>
  125. </el-table-column>
  126. <el-table-column prop="name" label="操作" width="100">
  127. <template v-slot="scope">
  128. <div class="ele_button table_row_button">
  129. <span class="btn_editor" @click="EditorSingle(scope.row)">编辑</span>
  130. <span class="btn_delete" @click="DeleteSingle(scope.row)">删除</span>
  131. </div>
  132. </template>
  133. </el-table-column>
  134. </el-table>
  135. <div class="page_info">
  136. 共{{tableData.length}}题 总分:{{totalScore}}分
  137. </div>
  138. </div>
  139. <div class="page_button">
  140. <el-button type="primary" style="width: 200px;" @click="FinishQuestion()">完成</el-button>
  141. </div>
  142. </div>
  143. <ChooseTemplate v-model="showChooseTemplate" :currentTemplateId="currentTemplateId" @success="onTemplateChanged"></ChooseTemplate>
  144. <EditorQuestion ref="enterStandardEdit" @AnswerEnter="EditorAnswerEnter"></EditorQuestion>
  145. <ImagePreview v-model="showPaperDialog" :imageList="previewImageList" ></ImagePreview>
  146. </div>
  147. </template>
  148. <script lang="ts" setup>
  149. import { useExamStore } from '@/store/exam'
  150. import { useRouter } from 'vue-router'
  151. import { onMounted,computed ,onUnmounted,ref,nextTick } from 'vue';
  152. import ChooseTemplate from './components/chooseTemplate.vue';
  153. import EditorQuestion from './components/EditorQuestion.vue';
  154. import ImagePreview from './components/ImagePreview.vue'
  155. import { ElMessageBox,ElMessage } from 'element-plus'
  156. import { getScanSetCertificate,getScanAnswerPreview,getScanAnswerStates,getSmartQuestionList,getTemplateInfo,setQuestion,saveQuestion,deleteAllQuestion,deleteSingleQuestion,getExamProcess} from '@/api/exam'
  157. import scanCommon from '@/utils/scanCommon.ts'
  158. import { useThrottleFn } from '@vueuse/core';
  159. import { useUserStore } from '@/store/user'
  160. // 实例化 Store
  161. const examStore = useExamStore()
  162. const router = useRouter()
  163. const userStore = useUserStore()
  164. // 修改 schoolId 计算属性,直接从 store 获取
  165. const selectSchoolId = computed(() => userStore.schoolId)
  166. // 定义题目数据的接口类型
  167. interface QuestionItem {
  168. id?: string;
  169. questionName?: string;
  170. questionType?: number; // 1:单选, 2:多选, 3:判断
  171. standardAnswer?: string;
  172. fullScore?: string | number;
  173. groupName?: string;
  174. optionsSize?: number;
  175. multipleRule?: string;
  176. multipleRuleDesc?: string;
  177. [key: string]: any; // 允许其他动态属性
  178. }
  179. const queryParams= ref({
  180. start:'',
  181. end:'',
  182. questionType:null,
  183. fullMark:'',
  184. groupName:'',//分组名称
  185. })
  186. //多选题「少选得分」(questionType==2 时使用),与模板 v-model="shaoxuan" 绑定
  187. const shaoxuan = ref('')//批量设置多选题给分分布的功能
  188. const scanText=ref('开始扫描');
  189. const templateQuestionCount=ref(0);//模板题目总数量
  190. const templateQuestionStart=ref(1);//模板题目起始编号
  191. const templateQuestionEnd=ref(0);//模板题目结束编号
  192. const currentTemplateId=ref(0);//当前模板ID
  193. const currentTemplateName=ref('');//当前模板名称
  194. const showChooseTemplate=ref(false);// 弹出选择模板的弹窗
  195. const tableData = ref<QuestionItem[]>([])
  196. const tableLoading = ref(false)//表格loading状态
  197. const enterStandardEdit=ref<HTMLElement>();//标准答案编辑组件实例
  198. const showPaperDialog=ref(false);//答案预览弹窗
  199. const previewImageList=ref<any[]>([]);//答案预览图片列表
  200. const previewAnswerData=ref<any>({});//答案预览数据
  201. const tableHeight = ref(400)// 表格高度
  202. const answerList=['A','B','C','D','E','F','G','H','I','J','K']//答案列表
  203. const tflist=['T','F']//判断题列表
  204. let uploadUrl=`https://www.k12100.com/teaching/api/v1/ai_exam_scan/upload_multi_img`;//图片上传地址
  205. let updateCountUrl=`https://www.k12100.com/teaching/api/v1/ai_exam_scan/record_batch_scan_number`;
  206. //扫描答案
  207. const scanLoading=ref(false);//开始扫描答案
  208. let skipCertificateNextScan = false;//未检测到纸张后,下次点击开始扫描时跳过后端证书接口,直接下发扫描仪指令
  209. const examSubjectCode=ref('');//扫描答案的科目编号
  210. const showScanAnswer=ref(false);//扫描答案预览
  211. const imageFilesList=ref<any[]>([]);//扫描答案的图片列表
  212. const scanAnswerLoading=ref(false);//确定扫描答案loading
  213. const scanClientConnected=ref(false);//扫描客户端的连接状态
  214. // 考试科目 ID
  215. const examSubjectId = computed(() => {
  216. return examStore.currentExam?.id
  217. })//计算属性
  218. const templateId=computed(() => {
  219. return examStore.currentExam?.templateId
  220. })//计算属性
  221. // 题目编号可选范围(从接口返回的 start 到 end)
  222. const questionRange=computed(() => {
  223. const start = Number(templateQuestionStart.value) || 1
  224. const end = Number(templateQuestionEnd.value) || 0
  225. if (end < start) return []
  226. const list: number[] = []
  227. for (let i = start; i <= end; i++) {
  228. list.push(i)
  229. }
  230. return list
  231. })
  232. const totalScore=computed(() => {
  233. if (!tableData.value || tableData.value.length === 0) {
  234. return 0;
  235. }
  236. // 累加所有题目的 fullScore,防止值为空或非数字时出错
  237. const sum = tableData.value.reduce((total, item) => {
  238. // 确保 fullScore 是有效数字,如果是空字符串或 null 则视为 0
  239. const score = Number(item.fullScore);
  240. return total + (isNaN(score) ? 0 : score);
  241. }, 0);
  242. return sum;
  243. })
  244. // 通用分数输入处理函数
  245. // target: 需要更新的目标对象 (如 scope.row 或 queryParams)
  246. // key: 目标对象中的属性名 (如 'fullScore' 或 'fullMark')
  247. // // val: 当前输入的值
  248. const onScoreInput = (target: any, key: string, val: any) => {
  249. // 1. 确保 val 是字符串
  250. let strVal = String(val || '');
  251. // 2. 正则替换:只保留数字和小数点
  252. strVal = strVal.replace(/[^\d.]/g, '');
  253. // 3. 逻辑:只允许一个小数点
  254. const parts = strVal.split('.');
  255. if (parts.length > 2) {
  256. strVal = parts[0] + '.' + parts.slice(1).join('');
  257. }
  258. // 4. 逻辑:限制总长度不超过3
  259. if (strVal.length > 3) {
  260. strVal = strVal.slice(0, 3);
  261. }
  262. // 5. 逻辑:如果不以数字开头且不为空,防止单独输入 '.'
  263. // 注意:如果允许 "0." 这种输入,则不需要此步,但通常 "0." 后面还得跟数字,这里暂时保留防止非法状态
  264. if (strVal === '.') {
  265. strVal = '';
  266. }
  267. // 6. 关键步骤:直接修改目标对象的属性
  268. if (target && key) {
  269. target[key] = strVal;
  270. }
  271. };
  272. // 组件顶层分数输入 ref 映射:当 obj 传 null 时,按 prop 名写入对应的 ref
  273. const scoreRefMap: Record<string, any> = { shaoxuan }
  274. // 少选得分校验(@change 触发):不能超过每题满分
  275. const ScoreYanzheng = () => {
  276. const fullMark = parseFloat(String(queryParams.value.fullMark ?? ''))
  277. const score = parseFloat(String(shaoxuan.value ?? ''))
  278. if (!isNaN(score) && !isNaN(fullMark) && score >= fullMark) {
  279. ElMessage.error('少选得分必须小于每题满分')
  280. shaoxuan.value = ''
  281. }
  282. }
  283. //打开选择模板弹窗
  284. const OpenChooseTemplate=()=>{
  285. showChooseTemplate.value=true;
  286. }
  287. //模板更换成功回调
  288. const onTemplateChanged=(templateData: any)=>{
  289. console.log('模板更换成功', templateData)
  290. currentTemplateId.value = templateData.id
  291. currentTemplateName.value = templateData.templateName
  292. // 更新 store 中的 templateId 并持久化到 localStorage
  293. examStore.setExamInfo({ ...examStore.currentExam, templateId: templateData.id })
  294. // 重新获取模板信息(含题目起止范围)并刷新试题列表
  295. GetTemplateInfo()
  296. GetSmartQuestionList()
  297. }
  298. //切换题目类型
  299. const TypeChange=(item:any)=>{
  300. console.log("打印题目类型",item)
  301. if (item.questionType == 2) {
  302. //切换到多选题:默认给分分布规则为「全对得满分,少选得0分,选错不得分」
  303. item.multipleRule = '1'
  304. item.multipleRuleDesc = '0'
  305. } else {
  306. //切换到单选/判断题:清空多选题给分规则相关字段,避免脏数据
  307. item.multipleRule = ''
  308. item.multipleRuleDesc = ''
  309. }
  310. }
  311. //确定扫描答案
  312. const EnterSubmit=()=>{
  313. }
  314. //单选点击事件
  315. const SingleClick=(item:any,answer:any)=>{
  316. console.log("打印单选点击",item,answer)
  317. item.standardAnswer = answer;
  318. console.log(item);//打印当前标准客户题题列表
  319. }
  320. // 多选点击事件
  321. const MultiClick=(answer:any,item:any)=>{
  322. console.log("打印多选点击",answer,item)
  323. // console.log("打印当前questionTable",this.questionTable);
  324. if(item.standardAnswer==undefined)
  325. {
  326. item.standardAnswer='';
  327. }
  328. // let answerlist=item.standardAnswer==''?[]:item.standardAnswer?.split(",");
  329. let answerlist = item.standardAnswer === '' ? [] : (item.standardAnswer || '').replace(/\s+/g, '').split(',').flatMap(item => item === '' ? [] : item.split(''));
  330. if (!answerlist?.includes(answer))
  331. {
  332. answerlist?.push(answer)
  333. }
  334. else
  335. {
  336. answerlist?.forEach((i,index) => {
  337. if (i === answer) {
  338. answerlist?.splice(index,1)
  339. }
  340. })
  341. }
  342. item.standardAnswer = answerlist?.join(",")
  343. // // 给当前题选择的答案赋值
  344. // for(var i=0;i<this.questionTable.length;i++)
  345. // {
  346. // if(item.id==this.questionTable[i].id)
  347. // {
  348. // this.questionTable[i].standardAnswer=item.standardAnswer;
  349. // }
  350. // }
  351. // console.log("打印当前questionTable",this.questionTable);
  352. }
  353. //删除全部 需要二次弹窗确认提示
  354. const DeleteAll=()=>{
  355. if(tableData.value.length==0)
  356. {
  357. ElMessage.error("您还没有添加题目哦!")
  358. return
  359. }
  360. console.log("打印删除全部")
  361. ElMessageBox.confirm('确认删除所有题目吗?', '提示', {
  362. confirmButtonText: '确定',
  363. cancelButtonText: '取消',
  364. type: 'warning',
  365. customClass:'page_dialog',
  366. }).then(async () => {
  367. console.log('删除考试')
  368. const param={
  369. id:examSubjectId.value,
  370. };
  371. const res = await deleteAllQuestion(param)
  372. if(res.code == 200)
  373. {
  374. ElMessage.success("删除成功!")
  375. GetExamStatus();//获取考试状态
  376. // GetSmartQuestionList();
  377. }
  378. else
  379. {
  380. ElMessage.error(res.msg)
  381. }
  382. })
  383. }
  384. //确定完成
  385. const FinishQuestion=async ()=>{
  386. console.log("打印完成")
  387. if(tableData.value.length==0)
  388. {
  389. ElMessage.error("请添加题目!")
  390. return
  391. }
  392. //校验:每题满分必须都设置
  393. const missingScoreRow = tableData.value.find((item: any) => {
  394. return item.fullScore === '' || item.fullScore === null || item.fullScore === undefined
  395. })
  396. if (missingScoreRow) {
  397. ElMessage.error(`请完善题目「${missingScoreRow.questionName}」的满分`)
  398. return
  399. }
  400. //校验:多选题必须设置给分分布规则
  401. const isRuleDescEmpty = (val: any) => val === '' || val === null || val === undefined || String(val).trim() === ''
  402. const missingRuleRow = tableData.value.find((item: any) => {
  403. if (item.questionType != 2) return false
  404. if (!item.multipleRule) return true
  405. return isRuleDescEmpty(item.multipleRuleDesc)
  406. })
  407. if (missingRuleRow) {
  408. ElMessage.error(`请完善题目「${missingRuleRow.questionName}」的多选题给分分布规则`)
  409. return
  410. }
  411. ElMessageBox.confirm('确认完成吗?', '提示', {
  412. confirmButtonText: '确定',
  413. cancelButtonText: '取消',
  414. type: 'warning',
  415. customClass:'page_dialog',
  416. }).then(async () => {
  417. console.log('完成',tableData.value)
  418. const res = await saveQuestion(tableData.value)
  419. if(res.code == 200)
  420. {
  421. ElMessage.success("保存成功!")
  422. GetExamStatus();//获取考试状态
  423. }
  424. else
  425. {
  426. ElMessage.error(res.msg)
  427. }
  428. })
  429. }
  430. //获取考试状态
  431. const GetExamStatus=async ()=>{
  432. const param = {
  433. examSubjectId:examSubjectId.value
  434. }
  435. getExamProcess(param).then((res: any) => {
  436. console.log('打印考试流程状态返回', res)
  437. if (res.code === 200) {
  438. const newStateStr = res.data;
  439. console.log('打印考试流程状态', newStateStr)
  440. // 替换为 Store Commit/Action
  441. examStore.setExamState(newStateStr);//设置状态数据
  442. if(newStateStr.includes(1))
  443. {
  444. router.push('/exam/scanList')
  445. }
  446. else
  447. {
  448. //未完成 不跳转
  449. GetSmartQuestionList();
  450. }
  451. }
  452. })
  453. }
  454. //确定编辑
  455. const EditorAnswerEnter=async (item:any)=>{
  456. console.log("打印确定编辑",item)
  457. let params=[];
  458. params.push(item);
  459. const res = await saveQuestion(params)
  460. if(res.code == 200)
  461. {
  462. ElMessage.success("保存成功!")
  463. GetSmartQuestionList();
  464. }
  465. else
  466. {
  467. ElMessage.error(res.msg)
  468. }
  469. }
  470. //删除单个题目 需要二次弹窗确认提示
  471. const DeleteSingle=(item:any)=>{
  472. console.log("打印删除单个",item)
  473. let msg = `确定要删除当前选择的<span style="color:#F56C6C;font-weight:600;">「${item.questionName}」</span>题吗?`;
  474. ElMessageBox.confirm(msg, '提示', {
  475. confirmButtonText: '确定',
  476. cancelButtonText: '取消',
  477. customClass:'page_dialog',
  478. dangerouslyUseHTMLString: true,
  479. type: 'warning'
  480. }).then(async () => {
  481. console.log('删除考试')
  482. const param={
  483. id:item.id,
  484. examSubjectId:examSubjectId.value,
  485. };
  486. const res = await deleteSingleQuestion(param)
  487. if(res.code == 200)
  488. {
  489. ElMessage.success("删除成功!")
  490. GetSmartQuestionList();
  491. }
  492. else
  493. {
  494. ElMessage.error(res.msg);
  495. }
  496. })
  497. }
  498. const ConfigRequestUrlByEnv= () =>{
  499. console.log("打印环境", import.meta.env.MODE);
  500. if (import.meta.env.MODE === 'development')
  501. {
  502. //本地开发环境:写死测试环境地址
  503. uploadUrl = `https://index.k12100.net/teaching/api/v1/ai_exam_scan/upload_multi_img`;//图片上传地址
  504. updateCountUrl = `https://index.k12100.net/teaching/api/v1/ai_exam_scan/record_batch_scan_number`;//更新扫描数量地址
  505. }
  506. else
  507. {
  508. //线上环境(测试/生产):读取配置的环境变量
  509. uploadUrl = `${import.meta.env.VITE_API_BASE_URL_API}/api/v1/ai_exam_scan/upload_multi_img`;//图片上传地址
  510. updateCountUrl = `${import.meta.env.VITE_API_BASE_URL_API}/api/v1/ai_exam_scan/record_batch_scan_number`;//更新扫描数量地址
  511. }
  512. console.log("打印上传图片地址", uploadUrl);
  513. console.log("打印更新扫描数量地址", updateCountUrl);
  514. }
  515. //编辑单个题目
  516. const EditorSingle=(item:any)=>{
  517. console.log("打印编辑单个题目",item)
  518. enterStandardEdit.value.InitData(item);
  519. }
  520. // 打开预览图片
  521. const OpenImage=()=>{
  522. let params={
  523. examSubjectId:examSubjectId.value,
  524. };
  525. getScanAnswerPreview(params).then((res:any)=>{
  526. console.log("打印预览图片返回结果",res);
  527. if(res.code==200)
  528. {
  529. previewImageList.value=[];//初始清空
  530. res.data.forEach((image:any)=>{
  531. console.log("image",image);
  532. let obj={
  533. url:image,
  534. pageNo:1,//页面页码
  535. };
  536. previewImageList.value.push(obj);
  537. })
  538. if(previewImageList.value.length>0)
  539. {
  540. showPaperDialog.value=true;
  541. }
  542. else
  543. {
  544. ElMessage.warning('您还未扫描答案哦!');
  545. }
  546. }
  547. })
  548. }
  549. // 打开扫描仪器
  550. const GotoScan = useThrottleFn(() => {
  551. // showSelectType.value = 'scan';
  552. console.log("打印打开扫描仪器",scanCommon.isClientConnected())
  553. if (!scanClientConnected.value) {
  554. ElMessage.warning('请先打开扫描客户端');
  555. return;
  556. }
  557. scanLoading.value = true;
  558. scanText.value='扫描中...';
  559. //未检测到纸张后的重试:跳过后端证书接口,直接下发扫描仪指令并恢复轮询
  560. if (skipCertificateNextScan) {
  561. skipCertificateNextScan = false;
  562. StartScan();
  563. ScheduleScanStatePolling();
  564. return;
  565. }
  566. // showScanAnswer.value=true;
  567. //先调用后端的接口
  568. const param={
  569. examSubjectId:examSubjectId.value,
  570. };
  571. getScanSetCertificate(param).then((res:any)=>{
  572. console.log("打印返回结果",res);
  573. if(res.code==200)
  574. {
  575. StartScan();
  576. }
  577. else
  578. {
  579. //后端证书接口失败:复位按钮,允许用户重新点击开始扫描
  580. scanLoading.value = false;
  581. scanText.value='开始扫描';
  582. ElMessage.error(res.msg);
  583. }
  584. })
  585. }, 5000);
  586. // 启动扫描
  587. const StartScan = () => {
  588. scanCommon.init(HandleScanResult); // 跟扫描仪插件 websocket 连接
  589. console.log("打印 VUE_APP_BASE_API", import.meta.env.VITE_APP_BASE_API); // Vue3/Vite 环境变量获取方式
  590. console.log("打印上传图片地址", uploadUrl);
  591. //开始扫描
  592. let jsonParam={
  593. examSubjectId:examSubjectId.value,
  594. batchNumber:'9999',//批次号 固定9999 代表扫描答案
  595. // sensitive: 35,//灵敏度参数 固定35
  596. settingFlag:1,//设置参数 (0-正常图片 1- 设置题目 以及答案)
  597. schoolId:selectSchoolId.value//学校id 联校单校都需要学校id
  598. };
  599. let json = {
  600. "action":"startScan",//交互指令参数
  601. "batchNumber":'9999',//批次号 这里传给客户端的批次号需要进行处理 截取批次id后5位拼接batchNumber
  602. "subjectCode":examSubjectCode.value,//科目编号
  603. "paperSchema":1,//this.paperSchema,// 单双面//页面类型 1 单面 2 双面
  604. "token":localStorage.getItem('token'),//token信息
  605. "uploadUrl":uploadUrl, // 图片上传地址
  606. "updateCountUrl":updateCountUrl,//更新扫描纸张数量接口 接口参数 批次id post 请求 id
  607. "batchId":'10000',//批次号id
  608. "dpi":150,//dpi参数 设置图片清晰度质量的
  609. "isColor":1,//是否彩色图片 手阅卡扫描彩色图片 0 黑白 1 彩色
  610. "useDriveUI":0,//startScan和scanTemplate增加useDriveUI参数,=1时会弹框,其他值或不写则不弹
  611. "jsonParam":JSON.stringify(jsonParam),// 后端使用的参数 json字符串 后端接口固定三个参数 1:jsonParam 2:seqNumber(这个批次的图片序号:从1开始) 3:file 图片文件
  612. };//正式版参数
  613. console.log("打印发送的数据",JSON.stringify(json));
  614. setTimeout(() => {
  615. scanCommon.send(JSON.stringify(json))
  616. },100)
  617. };
  618. //扫描状态轮询定时器
  619. let scanStateTimer: ReturnType<typeof setTimeout> | null = null;
  620. //停止扫描状态轮询
  621. const StopScanStatePolling=()=>{
  622. if(scanStateTimer){
  623. clearTimeout(scanStateTimer);
  624. scanStateTimer = null;
  625. }
  626. };
  627. //继续轮询:2秒后再次调用获取扫描答案状态
  628. const ScheduleScanStatePolling=()=>{
  629. StopScanStatePolling();//先清除已有定时器,避免重复轮询
  630. scanStateTimer = setTimeout(()=>{
  631. GetScanAnswerStates();
  632. },2000);
  633. };
  634. //获取扫描答案状态
  635. // 轮询查询识别是否完成,每隔2S调用一次,0-表示识别完成,1-上传中 2-识别中 3-失败,其中1和2继续保持loading 0和3可以加载题目
  636. const GetScanAnswerStates=()=>{
  637. const param={
  638. examSubjectId:examSubjectId.value,
  639. };
  640. getScanAnswerStates(param).then((res:any)=>{
  641. console.log("打印获取扫描答案状态",res);
  642. if(res.code==200)
  643. {
  644. //0-表示识别完成,1-上传中 2-识别中 3-失败,其中1和2继续保持loading 0和3可以加载题目
  645. if(res.data==0)
  646. {
  647. //识别完成:结束轮询,加载试题列表
  648. StopScanStatePolling();
  649. scanLoading.value = false;
  650. scanText.value='开始扫描';
  651. GetSmartQuestionList();//获取试题列表
  652. // showScanAnswer.value = true;
  653. }
  654. else if(res.data==1)
  655. {
  656. //上传中:保持loading,2秒后继续轮询
  657. scanLoading.value = true;
  658. scanText.value='上传中...';
  659. ScheduleScanStatePolling();
  660. }
  661. else if(res.data==2)
  662. {
  663. //识别中:保持loading,2秒后继续轮询
  664. scanLoading.value = true;
  665. scanText.value='识别中...';
  666. ScheduleScanStatePolling();
  667. }
  668. else if(res.data==3)
  669. {
  670. //扫描失败:结束轮询,加载试题列表
  671. StopScanStatePolling();
  672. scanLoading.value = false;
  673. scanText.value='扫描失败';
  674. ElMessage.error('扫描失败');
  675. GetSmartQuestionList();//获取试题列表
  676. }
  677. }
  678. else
  679. {
  680. //接口异常:结束轮询,避免无限请求
  681. StopScanStatePolling();
  682. scanLoading.value = false;
  683. ElMessage.error(res.msg);
  684. }
  685. })
  686. .catch(()=>{
  687. //请求异常:结束轮询,避免无限请求
  688. StopScanStatePolling();
  689. scanLoading.value = false;
  690. })
  691. };
  692. //关闭扫描答案预览
  693. const CloseScanAnswer=()=>{
  694. showScanAnswer.value = false;
  695. }
  696. // 4. 计算高度的函数
  697. const CalculateTableHeight = () => {
  698. // 获取父容器的高度
  699. const containerHeight = window.innerHeight
  700. // 减去其他固定元素的高度(需要根据实际布局调整这些数值)
  701. // 例如:header(64px) + 间距(20px) + 标题区域(约50px) + 底部按钮区域(约60px) + 分页信息(56px)
  702. // 建议通过浏览器控制台检查实际占用高度
  703. const offset = 320 // 这是一个预估值,请根据实际 CSS 调整
  704. tableHeight.value = containerHeight - offset
  705. }
  706. //获取模板信息
  707. const GetTemplateInfo=()=>{
  708. const params={
  709. templateId:currentTemplateId.value,
  710. };
  711. getTemplateInfo(params).then((res) => {
  712. console.log("打印获取模板信息结果",res)
  713. if(res.code==200)
  714. {
  715. templateQuestionCount.value=res.data.questionEnd;//题目数量
  716. templateQuestionStart.value=res.data.questionStart;//题目起始编号
  717. templateQuestionEnd.value=res.data.questionEnd;//题目结束编号
  718. // queryParams.value.end=res.data.questionEnd;//题目结束
  719. queryParams.value.start=res.data.questionStart;//题目开始
  720. currentTemplateName.value=res.data.templateName;//当前模板名称
  721. }
  722. })
  723. }
  724. //获取试题列表
  725. const GetSmartQuestionList=()=>{
  726. const params={
  727. examSubjectId: examSubjectId.value,
  728. };
  729. tableLoading.value = true;//开启表格loading
  730. getSmartQuestionList(params).then((res) => {
  731. console.log("打印获取试题结构列表结果",res)
  732. if(res.code==200)
  733. {
  734. // tableData.value=res.data;
  735. // 增加空值保护,确保 tableData 始终是数组
  736. tableData.value = Array.isArray(res.data) ? res.data : [];
  737. let result={};
  738. let maxQuestionNo = 0;//已有试题的最大题号
  739. for (const item of tableData.value) {
  740. // name转数字作为key
  741. const key = (item.questionName);
  742. result[key] = item.standardAnswer;
  743. // 提取 questionName 中的数字题号,兼容 "1" / "第1题" 等格式
  744. const match = String(item.questionName ?? '').match(/\d+/);
  745. if (match) {
  746. const no = Number(match[0]);
  747. if (no > maxQuestionNo) maxQuestionNo = no;
  748. }
  749. }
  750. previewAnswerData.value=result;
  751. console.log("打印转换后的数据",result)
  752. // 根据已有试题列表设置题目范围默认值
  753. // 根据规则设置起始题号默认值,并在设置后清空结尾题号(让用户自行选择)
  754. const endMax = Number(templateQuestionEnd.value) || 0;
  755. const startMin = Number(templateQuestionStart.value) || 1;
  756. // 记录本次更新列表前用户输入的结尾题号
  757. const prevEnd = Number(queryParams.value.end) || 0;
  758. if (prevEnd > 0) {
  759. // 用户输入了结尾题号:起始题号以「结尾题号 + 1」默认
  760. queryParams.value.start = prevEnd + 1;
  761. } else if (tableData.value.length > 0) {
  762. // 初始进来且已有试题:起始题号以「已有最大题号 + 1」默认
  763. queryParams.value.start = maxQuestionNo + 1;
  764. } else {
  765. // 无已有试题:回退为模板起始编号
  766. queryParams.value.start = startMin;
  767. }
  768. // 起始题号超出模板最大题数时,回退为模板起始编号
  769. if (Number(queryParams.value.start) > endMax) {
  770. queryParams.value.start = startMin;
  771. }
  772. // 起始题号设置好后清空结尾题号
  773. queryParams.value.end = '';
  774. }
  775. }).finally(()=>{
  776. tableLoading.value = false;//关闭表格loading
  777. })
  778. }
  779. //确定设置题目
  780. const EnterSetQuestion=()=>{
  781. console.log("打印提交参数",queryParams.value)
  782. if(!queryParams.value.end)
  783. {
  784. ElMessage.warning('请选择结束题号');
  785. return;
  786. }
  787. // 添加判断:end 不能小于 start
  788. if (queryParams.value.end < queryParams.value.start) {
  789. ElMessage.warning('结束题号不能小于起始题号');
  790. return;
  791. }
  792. //校验:题目类型必选
  793. if (!queryParams.value.questionType) {
  794. ElMessage.warning('请选择题目类型');
  795. return;
  796. }
  797. //校验:每题满分必填
  798. if (queryParams.value.fullMark === '' || queryParams.value.fullMark === null || queryParams.value.fullMark === undefined) {
  799. ElMessage.warning('请输入每题满分');
  800. return;
  801. }
  802. //校验:多选题必须填写少选得分
  803. if (queryParams.value.questionType == 2 && (shaoxuan.value === '' || shaoxuan.value === null || shaoxuan.value === undefined)) {
  804. ElMessage.warning('请输入多选题少选得分');
  805. return;
  806. }
  807. const params={
  808. examSubjectId: examSubjectId.value,//考试科目id
  809. questionMin: queryParams.value.start,//起始题号
  810. questionMax: queryParams.value.end,//结束题号
  811. questionType: queryParams.value.questionType,
  812. fullScore: queryParams.value.fullMark,
  813. groupName: queryParams.value.groupName,
  814. multipleRule: queryParams.value.questionType==2?'1':null,
  815. multipleRuleDesc: shaoxuan.value,
  816. };
  817. setQuestion(params).then((res) => {
  818. console.log("打印设置题目结果",res)
  819. if(res.code==200)
  820. {
  821. //设置成功 更新列表
  822. GetSmartQuestionList();
  823. }
  824. })
  825. }
  826. //扫描结果回调处理函数
  827. const HandleScanResult=(res:any)=>{
  828. console.log("打印扫描返回的res", res);
  829. //未检测到纸张或卡纸(code 502):停止轮询,复位按钮,允许用户重新点击开始扫描(下次直接下发扫描仪指令,不再调后端接口)
  830. if (res.code === 502) {
  831. StopScanStatePolling();
  832. scanLoading.value = false;
  833. scanText.value='开始扫描';
  834. skipCertificateNextScan = true;
  835. ElMessage.error(res.msg || '未检测到纸张或卡纸');
  836. return;
  837. }
  838. if (res.action === 'uploadFinish')
  839. {
  840. //扫描仪返回上传结束 开始启动轮询调用状态接口
  841. scanText.value='上传中...';
  842. ScheduleScanStatePolling();
  843. }
  844. }
  845. onMounted(() => {
  846. if (!examStore.currentExam) {
  847. console.warn('当前没有选中的考试信息')
  848. // 可选:如果没有数据,可以重定向回列表页或提示用户
  849. }
  850. scanCommon.init(HandleScanResult);
  851. scanCommon.watchConnection((isOnline, clientVersion) => {
  852. scanClientConnected.value = isOnline;
  853. // console.log('扫描客户端连接状态:', isOnline, '版本号:', clientVersion);
  854. });
  855. currentTemplateId.value=examStore.currentExam.templateId;
  856. ConfigRequestUrlByEnv();//配置请求地址
  857. GetScanAnswerStates();//获取扫描答案状态
  858. // GetSmartQuestionList();
  859. GetTemplateInfo();
  860. // 初始化计算
  861. nextTick(() => {
  862. CalculateTableHeight()
  863. })
  864. window.addEventListener('resize', CalculateTableHeight)
  865. })
  866. onUnmounted(() => {
  867. // 移除监听,防止内存泄漏
  868. window.removeEventListener('resize', CalculateTableHeight)
  869. // 停止扫描状态轮询,防止组件销毁后继续请求
  870. StopScanStatePolling();
  871. })
  872. </script>
  873. <style lang="scss" scoped>
  874. .detail_main
  875. {
  876. height: 100vh; /* 或 100% */
  877. display: flex;
  878. flex-direction: column;
  879. }
  880. .question_header
  881. {
  882. width: 100%;
  883. height: 64px;
  884. line-height: 64px;
  885. background: #FFFFFF;
  886. border-radius: 10px 10px 10px 10px;
  887. padding: 0 20px;
  888. box-sizing: border-box;
  889. display: flex;
  890. justify-content: space-between;
  891. .choose_template
  892. {
  893. font-weight: 500;
  894. font-size: 16px;
  895. color: #333333;
  896. .choose_btn
  897. {
  898. font-weight: 500;
  899. font-size: 16px;
  900. color: #2E64FA;
  901. margin-left: 10px;
  902. cursor: pointer;
  903. }
  904. }
  905. }
  906. .item_step
  907. {
  908. width: 20px;
  909. height: 20px;
  910. background: #2E64FA;
  911. border-radius: 50%;
  912. font-weight: 500;
  913. font-size: 14px;
  914. color: #FFFFFF;
  915. text-align: center;
  916. line-height: 20px;
  917. }
  918. .item_search
  919. {
  920. display: flex;
  921. align-items: center;
  922. margin-right: 10px;
  923. .search_name
  924. {
  925. width: auto;
  926. font-weight: 400;
  927. font-size: 16px;
  928. color: #666666;
  929. }
  930. .select_width
  931. {
  932. margin-left: 5px;
  933. margin-right: 5px;
  934. width: 90px;
  935. }
  936. }
  937. .item_title
  938. {
  939. font-weight: 500;
  940. font-size: 16px;
  941. color: #333333;
  942. display: flex;
  943. justify-content: flex-start;
  944. align-items: center;
  945. gap: 4px;
  946. position: relative;
  947. .preview_btn
  948. {
  949. font-weight: 500;
  950. font-size: 16px;
  951. color: #2E64FA;
  952. cursor: pointer;
  953. margin-left: 10px;
  954. }
  955. .delete_all
  956. {
  957. position: absolute;
  958. right: 0;
  959. top: 0;
  960. width: 100px;
  961. height: 36px;
  962. display: flex;
  963. justify-content: flex-end;
  964. align-items: center;
  965. .delete_btn
  966. {
  967. width: 88px;
  968. height: 34px;
  969. line-height: 34px;
  970. text-align: center;
  971. border-radius: 4px 4px 4px 4px;
  972. border: 1px solid #F56C6C;
  973. font-weight: 500;
  974. font-size: 14px;
  975. color: #F56C6C;
  976. cursor: pointer;
  977. }
  978. }
  979. }
  980. .page_info
  981. {
  982. font-weight: 500;
  983. font-size: 16px;
  984. color: #333333;
  985. text-align: right;
  986. height: 56px;
  987. line-height: 56px;
  988. }
  989. .page_button
  990. {
  991. text-align: center;
  992. }
  993. //标准答案录入
  994. .answer_input {
  995. width: 100%;
  996. padding-top: 8px;
  997. padding-bottom: 8px;
  998. margin: auto;
  999. // background-color: red;
  1000. display: flex;
  1001. justify-content: flex-start;
  1002. flex-wrap: wrap;
  1003. height: auto;
  1004. .single_selected {
  1005. width: auto;
  1006. margin: auto;
  1007. display: flex;
  1008. justify-content: flex-start;
  1009. flex-wrap: wrap;
  1010. gap: 8px;
  1011. .selected_button {
  1012. width: 32px;
  1013. height: 32px;
  1014. display: flex;
  1015. justify-content: center;
  1016. align-items: center;
  1017. margin-bottom: 5px;
  1018. padding: 0 !important;
  1019. border-radius: 50%;
  1020. margin: 0;
  1021. }
  1022. }
  1023. }
  1024. </style>