question.vue 41 KB

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