question.vue 41 KB

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