question.vue 35 KB

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