m-ExamWorkshop.vue 99 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261
  1. <template>
  2. <div class="mobile-exam-workshop">
  3. <!-- 移动端考试工坊页面 -->
  4. <MobileHeader title="考试工坊" @back="goBack" @menu="showHistoryDrawer" />
  5. <div class="mobile-content">
  6. <!-- 通用历史记录抽屉 -->
  7. <MobileHistoryDrawer
  8. :visible="!isGenerating && showHistory"
  9. title="历史记录"
  10. :historyData="historyData"
  11. :loading="isLoadingHistory"
  12. @close="showHistory = false"
  13. @createNewTask="createNewTask"
  14. @handleHistoryItem="handleHistoryItem"
  15. @deleteHistoryItem="deleteHistoryItem"
  16. />
  17. <!-- 移动端Toast提示组件 -->
  18. <MobileToast :visible="toastVisible" :message="toastMessage" @close="closeToast" />
  19. <!-- 主界面:考试工坊配置 -->
  20. <div v-if="!showExamDetail" class="exam-workshop-main">
  21. <!-- 试卷类型选择 -->
  22. <div class="config-section">
  23. <div class="config-header">
  24. <div class="step-number">1</div>
  25. <h3>选择试卷类型</h3>
  26. </div>
  27. <div class="type-cards">
  28. <div class="type-cards-row">
  29. <div
  30. v-for="(type, key) in projectTypes"
  31. :key="key"
  32. :class="['type-card', { active: selectedProjectType === key }]"
  33. @click="(isGenerating || selectedFile) ? null : selectProjectType(key)"
  34. :style="{ cursor: (isGenerating || selectedFile) ? 'not-allowed' : 'pointer', opacity: (isGenerating || selectedFile) ? '0.5' : '1' }"
  35. >
  36. <img :src="type.icon" :alt="type.name" class="type-icon" />
  37. <span>{{ type.name }}</span>
  38. </div>
  39. </div>
  40. </div>
  41. </div>
  42. <!-- 生成方式选择 -->
  43. <div class="config-section">
  44. <div class="config-header">
  45. <div class="step-number">2</div>
  46. <h3>选择生成方式</h3>
  47. </div>
  48. <div class="generation-methods">
  49. <div
  50. :class="['method-card', { active: selectedFunction === 'ai' }]"
  51. @click="(isGenerating || selectedFile) ? null : selectFunction('ai')"
  52. :style="{ cursor: (isGenerating || selectedFile) ? 'not-allowed' : 'pointer', opacity: (isGenerating || selectedFile) ? '0.5' : '1' }"
  53. >
  54. <img :src="aiIcon" alt="智能生成试卷" class="method-icon" />
  55. <div class="method-content">
  56. <h4>智能生成试卷</h4>
  57. <p>基于AI技术,根据所选类型自动生成完整试卷</p>
  58. </div>
  59. </div>
  60. <!-- PPT生成考题选项已隐藏 -->
  61. </div>
  62. </div>
  63. <!-- 试卷配置 -->
  64. <div class="config-section">
  65. <div class="config-header">
  66. <div class="step-number">3</div>
  67. <h3>试卷配置</h3>
  68. </div>
  69. <div class="exam-config-container">
  70. <div class="config-main">
  71. <div class="config-form">
  72. <div class="form-group">
  73. <label>试卷名称</label>
  74. <div class="input-wrapper">
  75. <input
  76. v-model="examName"
  77. type="text"
  78. placeholder="请输入试卷名称"
  79. class="config-input"
  80. maxlength="32"
  81. @input="validateExamName"
  82. :disabled="isGenerating || selectedFile"
  83. />
  84. <span class="char-count" :class="{ 'warning': examName.length >= 18 }">{{ examName.length }}/32</span>
  85. </div>
  86. </div>
  87. <div class="form-group">
  88. <label>试卷总分</label>
  89. <div class="score-input">
  90. <input
  91. v-model="totalScore"
  92. type="number"
  93. class="config-input"
  94. min="1"
  95. max="1000"
  96. @input="validateTotalScore"
  97. :disabled="isGenerating || selectedFile"
  98. />
  99. <span class="unit">分</span>
  100. </div>
  101. </div>
  102. </div>
  103. <!-- 题型配置 -->
  104. <div class="question-types-title">题型选择与分数分配</div>
  105. <div class="question-types">
  106. <div
  107. class="question-type"
  108. v-for="(type, index) in questionTypes"
  109. :key="index"
  110. >
  111. <div class="type-header">
  112. <span class="type-name">{{ type.name }}</span>
  113. <div class="progress-bar">
  114. <div class="progress-fill" :style="{ width: ((type.scorePerQuestion * type.questionCount) / totalScore) * 100 + '%' }"></div>
  115. </div>
  116. </div>
  117. <div class="score-config">
  118. <div class="config-item">
  119. <span>每题</span>
  120. <input
  121. v-model="type.scorePerQuestion"
  122. type="number"
  123. class="score-input-field"
  124. min="1"
  125. max="99"
  126. @input="validateScorePerQuestion(type)"
  127. :disabled="isGenerating || selectedFile"
  128. />
  129. <span>分</span>
  130. </div>
  131. <div class="config-item">
  132. <span>一共</span>
  133. <div class="count-stepper">
  134. <input
  135. v-model="type.questionCount"
  136. type="number"
  137. class="count-input-field"
  138. min="0"
  139. max="99"
  140. @input="validateQuestionCount(type)"
  141. :disabled="isGenerating || selectedFile"
  142. />
  143. <div class="stepper-buttons">
  144. <button
  145. class="stepper-btn stepper-btn-up"
  146. type="button"
  147. @click="adjustQuestionCount(type, 1)"
  148. :disabled="isGenerating || selectedFile || type.questionCount >= 99"
  149. aria-label="增加题目数量"
  150. ></button>
  151. <button
  152. class="stepper-btn stepper-btn-down"
  153. type="button"
  154. @click="adjustQuestionCount(type, -1)"
  155. :disabled="isGenerating || selectedFile || type.questionCount <= 0"
  156. aria-label="减少题目数量"
  157. ></button>
  158. </div>
  159. </div>
  160. <span>题</span>
  161. </div>
  162. </div>
  163. </div>
  164. </div>
  165. </div>
  166. <!-- 预览面板 -->
  167. <div class="preview-panel">
  168. <div class="preview-header">
  169. <img :src="previewIcon" alt="预览" class="preview-icon" />
  170. <h3>预览</h3>
  171. </div>
  172. <div class="preview-content">
  173. <h4 class="preview-title">{{ examName || "试卷名称" }}</h4>
  174. <div class="question-breakdown">
  175. <div
  176. class="breakdown-item"
  177. v-for="(type, index) in questionTypes"
  178. :key="index"
  179. >
  180. <div class="breakdown-row">
  181. <span class="breakdown-left">{{ type.romanNumeral }}、{{ type.name }} (每题{{ type.scorePerQuestion }}分,共{{ type.scorePerQuestion * type.questionCount }}分)</span>
  182. <span class="breakdown-right">{{ type.questionCount }}题</span>
  183. </div>
  184. </div>
  185. </div>
  186. <div class="divider"></div>
  187. <div class="calculated-score-row">
  188. <span class="calculated-label">配置总分</span>
  189. <span class="calculated-value">{{ calculatedTotalScore }}分</span>
  190. </div>
  191. <div class="total-score-row">
  192. <span class="total-label">试卷总分</span>
  193. <span class="total-value">{{ totalScore }}分</span>
  194. </div>
  195. </div>
  196. </div>
  197. </div>
  198. <!-- 底部操作按钮 -->
  199. <div class="bottom-actions">
  200. <button class="clear-btn" @click="clearSettings" :disabled="isGenerating || selectedFile">
  201. 一键清除
  202. </button>
  203. <button class="generate-btn" @click="generateExam" :disabled="isGenerating">
  204. <img v-if="!isGenerating" :src="generateIcon" alt="生成试卷" class="generate-icon" />
  205. <span v-else class="generating-text">生成中...</span>
  206. </button>
  207. </div>
  208. </div>
  209. </div>
  210. <!-- 考试详情页 -->
  211. <div v-if="showExamDetail" class="exam-detail-main">
  212. <!-- 详情页头部 -->
  213. <div class="detail-header">
  214. <button class="back-btn" @click="backToConfig" :disabled="isGenerating">
  215. <span class="back-arrow">←</span>
  216. 返回修改
  217. </button>
  218. <div class="download-dropdown" :class="{ 'disabled': isGenerating, 'show': showDownloadMenu }" @click.stop>
  219. <button class="download-btn" :disabled="isGenerating" @click="toggleDownloadMenu">
  220. <img :src="downloadIcon" alt="下载Word" class="download-icon" />
  221. </button>
  222. <div class="dropdown-menu">
  223. <div class="dropdown-item" @click="exportToWordWithAnswers" :disabled="isGenerating">
  224. <span class="item-text">有答案</span>
  225. </div>
  226. <div class="dropdown-item" @click="exportToWordWithoutAnswers" :disabled="isGenerating">
  227. <span class="item-text">无答案</span>
  228. </div>
  229. </div>
  230. </div>
  231. </div>
  232. <!-- 试卷信息 -->
  233. <div class="exam-info">
  234. <h1 class="exam-title">{{ currentExam.title }}</h1>
  235. <div class="exam-stats">
  236. <span class="total-score">总分: {{ currentExam.totalScore }}分</span>
  237. <span class="question-count">题量: {{ currentExam.totalQuestions }}题</span>
  238. </div>
  239. <div class="generation-time">生成时间: {{ currentTime }}</div>
  240. </div>
  241. <!-- 题型列表 -->
  242. <div class="question-sections">
  243. <!-- 单选题 -->
  244. <div class="question-section" v-if="currentExam.singleChoice && currentExam.singleChoice.questions.length > 0">
  245. <div class="section-header" @click="isGenerating ? null : toggleSection('single')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
  246. <div class="section-title">
  247. <span class="section-number">一</span>
  248. <span class="section-name">单选题</span>
  249. <span class="section-score">(每题{{ currentExam.singleChoice.scorePerQuestion }}分, 共{{ currentExam.singleChoice.totalScore }}分)</span>
  250. </div>
  251. <div class="section-controls">
  252. <span class="question-count-text">{{ currentExam.singleChoice.count }}题</span>
  253. <img
  254. :src="expandIcon"
  255. alt="收起/展开"
  256. class="toggle-icon"
  257. :class="{ 'expanded': !expandedSections.single }"
  258. />
  259. </div>
  260. </div>
  261. <div v-if="expandedSections.single" class="section-content">
  262. <div
  263. v-for="(question, index) in currentExam.singleChoice.questions"
  264. :key="index"
  265. class="question-item"
  266. >
  267. <div class="question-header">
  268. <span class="question-number">{{ index + 1 }}.</span>
  269. <span class="question-text">{{ question.text }}</span>
  270. <button class="refresh-btn" @click="refreshQuestion('single', index)" :disabled="isGenerating">
  271. <img
  272. :src="collapseIcon"
  273. alt="刷新"
  274. class="refresh-icon"
  275. :class="{ 'rotating': isRefreshing['single_' + index] }"
  276. />
  277. </button>
  278. </div>
  279. <div class="options">
  280. <div
  281. v-for="option in question.options"
  282. :key="option.key"
  283. class="option"
  284. >
  285. <div class="radio-wrapper">
  286. <div class="radio-circle" :class="{ 'selected': question.selectedAnswer === option.key }">
  287. <div v-if="question.selectedAnswer === option.key" class="radio-dot"></div>
  288. </div>
  289. </div>
  290. <span class="option-key">{{ option.key }}.</span>
  291. <div class="option-content">
  292. <span class="option-text">{{ option.text }}</span>
  293. </div>
  294. </div>
  295. </div>
  296. </div>
  297. </div>
  298. </div>
  299. <!-- 判断题 -->
  300. <div class="question-section" v-if="currentExam.judge && currentExam.judge.questions.length > 0">
  301. <div class="section-header" @click="isGenerating ? null : toggleSection('judge')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
  302. <div class="section-title">
  303. <span class="section-number">二</span>
  304. <span class="section-name">判断题</span>
  305. <span class="section-score">(每题{{ currentExam.judge.scorePerQuestion }}分, 共{{ currentExam.judge.totalScore }}分)</span>
  306. </div>
  307. <div class="section-controls">
  308. <span class="question-count-text">{{ currentExam.judge.count }}题</span>
  309. <img
  310. :src="expandIcon"
  311. alt="收起/展开"
  312. class="toggle-icon"
  313. :class="{ 'expanded': !expandedSections.judge }"
  314. />
  315. </div>
  316. </div>
  317. <div v-if="expandedSections.judge" class="section-content">
  318. <div
  319. v-for="(question, index) in currentExam.judge.questions"
  320. :key="index"
  321. class="question-item"
  322. >
  323. <div class="question-header">
  324. <span class="question-number">{{ index + 1 }}.</span>
  325. <span class="question-text">{{ question.text }}</span>
  326. <button class="refresh-btn" @click="refreshQuestion('judge', index)" :disabled="isGenerating">
  327. <img
  328. :src="collapseIcon"
  329. alt="刷新"
  330. class="refresh-icon"
  331. :class="{ 'rotating': isRefreshing['judge_' + index] }"
  332. />
  333. </button>
  334. </div>
  335. <div class="answer-section">
  336. <span class="answer-label">正确答案:</span>
  337. <span class="answer-value">{{ question.selectedAnswer }}</span>
  338. </div>
  339. </div>
  340. </div>
  341. </div>
  342. <!-- 多选题 -->
  343. <div class="question-section" v-if="currentExam.multiple && currentExam.multiple.questions.length > 0">
  344. <div class="section-header" @click="isGenerating ? null : toggleSection('multiple')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
  345. <div class="section-title">
  346. <span class="section-number">三</span>
  347. <span class="section-name">多选题</span>
  348. <span class="section-score">(每题{{ currentExam.multiple.scorePerQuestion }}分, 共{{ currentExam.multiple.totalScore }}分)</span>
  349. </div>
  350. <div class="section-controls">
  351. <span class="question-count-text">{{ currentExam.multiple.count }}题</span>
  352. <img
  353. :src="expandIcon"
  354. alt="收起/展开"
  355. class="toggle-icon"
  356. :class="{ 'expanded': !expandedSections.multiple }"
  357. />
  358. </div>
  359. </div>
  360. <div v-if="expandedSections.multiple" class="section-content">
  361. <div
  362. v-for="(question, index) in currentExam.multiple.questions"
  363. :key="index"
  364. class="question-item"
  365. >
  366. <div class="question-header">
  367. <span class="question-number">{{ index + 1 }}.</span>
  368. <span class="question-text">{{ question.text }}</span>
  369. <button class="refresh-btn" @click="refreshQuestion('multiple', index)" :disabled="isGenerating">
  370. <img
  371. :src="collapseIcon"
  372. alt="刷新"
  373. class="refresh-icon"
  374. :class="{ 'rotating': isRefreshing['multiple_' + index] }"
  375. />
  376. </button>
  377. </div>
  378. <div class="options">
  379. <div
  380. v-for="option in question.options"
  381. :key="option.key"
  382. class="option"
  383. >
  384. <div class="radio-wrapper">
  385. <div class="radio-circle" :class="{ 'selected': (question.selectedAnswers || []).includes(option.key) }">
  386. <div v-if="(question.selectedAnswers || []).includes(option.key)" class="radio-dot"></div>
  387. </div>
  388. </div>
  389. <span class="option-key">{{ option.key }}.</span>
  390. <div class="option-content">
  391. <span class="option-text">{{ option.text }}</span>
  392. </div>
  393. </div>
  394. </div>
  395. <div class="answer-section">
  396. <span class="answer-label">正确答案:</span>
  397. <span class="answer-value">{{ (question.selectedAnswers || []).join(', ') }}</span>
  398. </div>
  399. </div>
  400. </div>
  401. </div>
  402. <!-- 简答题 -->
  403. <div class="question-section" v-if="currentExam.short && currentExam.short.questions.length > 0">
  404. <div class="section-header" @click="isGenerating ? null : toggleSection('short')" :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer' }">
  405. <div class="section-title">
  406. <span class="section-number">四</span>
  407. <span class="section-name">简答题</span>
  408. <span class="section-score">(每题{{ currentExam.short.scorePerQuestion }}分, 共{{ currentExam.short.totalScore }}分)</span>
  409. </div>
  410. <div class="section-controls">
  411. <span class="question-count-text">{{ currentExam.short.count }}题</span>
  412. <img
  413. :src="expandIcon"
  414. alt="收起/展开"
  415. class="toggle-icon"
  416. :class="{ 'expanded': !expandedSections.short }"
  417. />
  418. </div>
  419. </div>
  420. <div v-if="expandedSections.short" class="section-content">
  421. <div
  422. v-for="(question, index) in currentExam.short.questions"
  423. :key="index"
  424. class="question-item"
  425. >
  426. <div class="question-header">
  427. <span class="question-number">{{ index + 1 }}.</span>
  428. <span class="question-text">{{ question.text }}</span>
  429. <button class="refresh-btn" @click="refreshQuestion('short', index)" :disabled="isGenerating">
  430. <img
  431. :src="collapseIcon"
  432. alt="刷新"
  433. class="refresh-icon"
  434. :class="{ 'rotating': isRefreshing['short_' + index] }"
  435. />
  436. </button>
  437. </div>
  438. <div v-if="question.outline" class="answer-outline">
  439. <div class="outline-section">
  440. <strong>关键要点:</strong>{{ cleanText(question.outline.keyFactors) }}
  441. </div>
  442. <div class="outline-section">
  443. <strong>具体措施:</strong>{{ cleanText(question.outline.measures) }}
  444. </div>
  445. </div>
  446. </div>
  447. </div>
  448. </div>
  449. </div>
  450. </div>
  451. <!-- 文件上传隐藏input -->
  452. <input
  453. ref="fileInput"
  454. type="file"
  455. accept=".ppt,.pptx"
  456. @change="handleFileSelect"
  457. style="display: none"
  458. />
  459. </div>
  460. </div>
  461. </template>
  462. <script setup>
  463. import { useRouter } from 'vue-router'
  464. import MobileHeader from '@/components/MobileHeader.vue'
  465. import MobileHistoryDrawer from '@/components/MobileHistoryDrawer.vue'
  466. import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
  467. import MobileToast from '@/components/MobileToast.vue'
  468. import { apis } from '@/request/apis.js'
  469. import { initNativeNavForSubPage } from '@/utils/nativeBridge.js'
  470. // ===== 已删除:getUserId - 不再需要,改用token =====
  471. // import { getUserId } from '@/utils/userManager.js'
  472. const router = useRouter()
  473. const goBack = () => {
  474. router.go(-1)
  475. }
  476. // 显示历史记录抽屉的方法
  477. const showHistoryDrawer = () => {
  478. if (!isGenerating.value) {
  479. showHistory.value = true
  480. }
  481. // AI处理中时不执行任何操作,不记录点击意图
  482. }
  483. const showHistory = ref(false)
  484. // Toast 状态管理(与其他移动端页面保持一致)
  485. const toastVisible = ref(false)
  486. const toastMessage = ref('')
  487. const showToast = (message, duration = 2000) => {
  488. toastMessage.value = message
  489. toastVisible.value = true
  490. if (duration > 0) {
  491. setTimeout(() => {
  492. toastVisible.value = false
  493. }, duration)
  494. }
  495. }
  496. const closeToast = () => {
  497. toastVisible.value = false
  498. }
  499. // 历史记录相关状态
  500. const historyData = ref([])
  501. const historyTotal = ref(0)
  502. const isLoadingHistory = ref(false)
  503. // 页面加载时不再自动加载历史记录,改为点击菜单时加载
  504. onMounted(async () => {
  505. // 保存初始配置
  506. initialConfig = {
  507. questionTypes: JSON.parse(JSON.stringify(questionTypes.value)),
  508. totalScore: totalScore.value,
  509. selectedProjectType: selectedProjectType.value,
  510. examName: examName.value
  511. };
  512. console.log('初始配置已保存:', initialConfig);
  513. // 添加全局点击事件监听器
  514. document.addEventListener('click', handleClickOutside);
  515. console.log('🚀 移动端考试工坊页面初始化完成')
  516. })
  517. onUnmounted(() => {
  518. // 清理事件监听器
  519. document.removeEventListener('click', handleClickOutside);
  520. })
  521. // ============ 移动端考试工坊核心功能 ============
  522. // 试卷类型图标和配置
  523. import bridgeIcon from '@/assets/Exam/4.png'
  524. import tunnelIcon from '@/assets/Exam/18.png'
  525. import equipmentIcon from '@/assets/Exam/5.png'
  526. import gasStationIcon from '@/assets/Exam/6.png'
  527. import highwayIcon from '@/assets/Exam/7.png'
  528. import comprehensiveIcon from '@/assets/Exam/8.png'
  529. import aiIcon from '@/assets/Exam/7.png'
  530. import pptIcon from '@/assets/Exam/8.png'
  531. import previewIcon from '@/assets/Exam/9.png'
  532. import clearIcon from '@/assets/Exam/10.png'
  533. import generateIcon from '@/assets/Exam/12.png'
  534. import downloadIcon from '@/assets/Exam/13.png'
  535. import expandIcon from '@/assets/Exam/17.png'
  536. import collapseIcon from '@/assets/Exam/16.png'
  537. import saveIcon from '@/assets/Exam/15.png'
  538. // 试卷类型配置
  539. const projectTypes = {
  540. bridge: { name: "桥梁", icon: bridgeIcon },
  541. tunnel: { name: "隧道", icon: tunnelIcon },
  542. equipment: { name: "特种设备", icon: equipmentIcon },
  543. "gas-station": { name: "加油站", icon: gasStationIcon },
  544. highway: { name: "高速运营公路", icon: highwayIcon },
  545. comprehensive: { name: "综合", icon: comprehensiveIcon },
  546. };
  547. // 题型配置
  548. const questionTypes = ref([
  549. { name: "单选题", scorePerQuestion: 5, questionCount: 5, romanNumeral: "一" },
  550. { name: "判断题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "二" },
  551. { name: "多选题", scorePerQuestion: 8, questionCount: 5, romanNumeral: "三" },
  552. { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
  553. ]);
  554. // 考试工坊状态
  555. const showExamDetail = ref(false)
  556. const isGenerating = ref(false)
  557. const isLoadingHistoryItem = ref(false)
  558. const ai_conversation_id = ref(0)
  559. const showDownloadMenu = ref(false) // 控制下载菜单显示状态
  560. // 试卷配置状态
  561. const selectedFunction = ref("ai")
  562. const selectedProjectType = ref("bridge")
  563. const examName = ref("桥梁工程施工技术考核")
  564. const totalScore = ref(100)
  565. const currentTime = ref("")
  566. // 文件上传相关
  567. const selectedFile = ref(null)
  568. const pptContentDescription = ref('')
  569. const fileInput = ref(null)
  570. // 展开/收起状态
  571. const expandedSections = ref({
  572. single: true,
  573. judge: true,
  574. multiple: true,
  575. short: true,
  576. })
  577. // 刷新状态记录
  578. const isRefreshing = ref({})
  579. // 当前试卷数据
  580. const currentExam = ref({
  581. title: "桥梁工程施工技术考核",
  582. totalScore: 100,
  583. totalQuestions: 37,
  584. singleChoice: {
  585. scorePerQuestion: 2,
  586. totalScore: 30,
  587. count: 15,
  588. questions: []
  589. },
  590. judge: {
  591. scorePerQuestion: 2,
  592. totalScore: 20,
  593. count: 10,
  594. questions: []
  595. },
  596. multiple: {
  597. scorePerQuestion: 3,
  598. totalScore: 30,
  599. count: 10,
  600. questions: []
  601. },
  602. short: {
  603. scorePerQuestion: 10,
  604. totalScore: 20,
  605. count: 2,
  606. questions: []
  607. }
  608. })
  609. // 保存初始配置
  610. let initialConfig = null
  611. // 计算总分(所有题目分数的总和)
  612. const calculatedTotalScore = computed(() => {
  613. return questionTypes.value.reduce((total, type) => {
  614. return total + (type.scorePerQuestion * type.questionCount);
  615. }, 0);
  616. })
  617. // ============ 历史记录相关方法 ============
  618. // 格式化时间函数
  619. const formatTime = (timestamp) => {
  620. if (!timestamp) return ''
  621. const date = new Date(timestamp)
  622. const now = new Date()
  623. const diff = now - date
  624. // 如果是今天
  625. if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
  626. return date.toLocaleTimeString('zh-CN', {
  627. hour: '2-digit',
  628. minute: '2-digit'
  629. })
  630. }
  631. // 如果是昨天
  632. if (diff < 48 * 60 * 60 * 1000 && date.getDate() === now.getDate() - 1) {
  633. return '昨天 ' + date.toLocaleTimeString('zh-CN', {
  634. hour: '2-digit',
  635. minute: '2-digit'
  636. })
  637. }
  638. // 其他情况显示日期
  639. return date.toLocaleDateString('zh-CN', {
  640. month: '2-digit',
  641. day: '2-digit'
  642. })
  643. }
  644. // 生成对话标题
  645. const generateConversationTitle = (content) => {
  646. if (!content) return '新对话'
  647. // 取前30个字符作为标题
  648. const title = content.replace(/<[^>]*>/g, '').trim()
  649. return title.length > 30 ? title.substring(0, 30) + '...' : title
  650. }
  651. // 新建任务
  652. const createNewTask = () => {
  653. console.log('新建考试工坊任务')
  654. showHistory.value = false
  655. // 重置所有状态到新任务状态
  656. selectedFunction.value = "ai"
  657. selectedProjectType.value = "bridge"
  658. examName.value = "桥梁工程施工技术考核"
  659. totalScore.value = 100
  660. showExamDetail.value = false
  661. selectedFile.value = null
  662. pptContentDescription.value = ''
  663. ai_conversation_id.value = 0
  664. // 重置题型配置
  665. if (initialConfig) {
  666. questionTypes.value = JSON.parse(JSON.stringify(initialConfig.questionTypes))
  667. totalScore.value = initialConfig.totalScore
  668. selectedProjectType.value = initialConfig.selectedProjectType
  669. examName.value = initialConfig.examName
  670. }
  671. // 清除历史记录的激活状态
  672. historyData.value.forEach((item) => {
  673. item.isActive = false
  674. })
  675. }
  676. // 处理历史记录点击
  677. const handleHistoryItem = async (historyItem) => {
  678. if (isGenerating.value || isLoadingHistoryItem.value) return
  679. console.log("点击移动端考试工坊历史记录:", historyItem)
  680. ai_conversation_id.value = historyItem.id
  681. isLoadingHistoryItem.value = true
  682. try {
  683. historyData.value.forEach((item) => {
  684. item.isActive = item.id === historyItem.id
  685. })
  686. showHistory.value = false
  687. const response = await apis.getHistoryRecord({
  688. ai_conversation_id: historyItem.id,
  689. business_type: 3
  690. })
  691. if (response.statusCode === 200 && response.data && response.data.length > 0) {
  692. const latestAiRecord = [...response.data].reverse().find(record => record.type === 'ai' && record.content)
  693. if (latestAiRecord?.content) {
  694. try {
  695. const examData = extractExamDataFromContent(latestAiRecord.content)
  696. restoreExamFromHistory(examData)
  697. currentTime.value = historyItem.time
  698. showExamDetail.value = true
  699. return
  700. } catch (error) {
  701. console.error('解析移动端历史试卷数据失败:', error)
  702. }
  703. }
  704. }
  705. console.error('移动端历史记录缺少可恢复的试卷详情:', response)
  706. showToast('该历史记录暂无可恢复的试卷内容')
  707. } catch (error) {
  708. console.error('获取移动端历史记录详情失败:', error)
  709. showToast('获取历史记录详情失败,请重试')
  710. } finally {
  711. isLoadingHistoryItem.value = false
  712. }
  713. }
  714. // 删除历史记录
  715. const deleteHistoryItem = async (historyItem, index) => {
  716. try {
  717. console.log('开始删除移动端历史记录:', historyItem)
  718. const response = await apis.deleteHistoryRecord({
  719. // ===== 已删除:user_id - 后端从token解析 =====
  720. ai_conversation_id: historyItem.id
  721. })
  722. if (response.statusCode === 200) {
  723. // 从本地数据中移除
  724. historyData.value.splice(index, 1)
  725. historyTotal.value = Math.max(0, historyTotal.value - 1)
  726. // 如果删除的是当前激活的历史记录,执行新建任务
  727. if (historyItem.isActive) {
  728. console.log('删除激活的历史记录,执行新建任务')
  729. createNewTask()
  730. }
  731. console.log('✅ 移动端历史记录删除成功')
  732. // 轻提示
  733. showToast('删除成功')
  734. } else {
  735. console.error('❌ 删除移动端历史记录失败:', response)
  736. }
  737. } catch (error) {
  738. console.error('❌ 删除移动端历史记录失败:', error)
  739. }
  740. }
  741. // 时间解析与格式化(容错更强)
  742. const parseToDate = (input) => {
  743. if (!input) return null
  744. if (typeof input === 'number') {
  745. const ms = input < 1e12 ? input * 1000 : input
  746. return new Date(ms)
  747. }
  748. if (typeof input === 'string') {
  749. let d = new Date(input)
  750. if (!isNaN(d)) return d
  751. const normalized = input.replace(/-/g, '/').replace('T', ' ')
  752. d = new Date(normalized)
  753. if (!isNaN(d)) return d
  754. }
  755. return new Date(input)
  756. }
  757. const formatHistoryTime = (timestamp) => {
  758. const date = parseToDate(timestamp)
  759. if (!date || isNaN(date)) return '未知时间'
  760. const now = new Date()
  761. const isToday = date.toDateString() === now.toDateString()
  762. const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
  763. const isYesterday = date.toDateString() === yesterday.toDateString()
  764. if (isToday) {
  765. return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  766. }
  767. if (isYesterday) {
  768. return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  769. }
  770. const month = date.getMonth() + 1
  771. const day = date.getDate()
  772. const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  773. return `${month}月${day}日 ${time}`
  774. }
  775. // 获取历史记录列表
  776. const isExamWorkshopConversation = (conversation = {}) => {
  777. const content = String(conversation.content || '')
  778. const title = String(conversation.title || '')
  779. const examName = String(conversation.exam_name || '')
  780. return (
  781. Number(conversation.business_type) === 3 ||
  782. !!examName.trim() ||
  783. title.includes('技术考核') ||
  784. content.includes('请根据以下要求直接生成一份完整试卷') ||
  785. content.includes('"singleChoice"') ||
  786. content.includes('"totalQuestions"')
  787. )
  788. }
  789. const getHistoryRecordList = async () => {
  790. try {
  791. console.log('📋 开始获取移动端考试工坊历史记录列表...')
  792. isLoadingHistory.value = true
  793. const startTime = performance.now()
  794. const response = await apis.getHistoryRecord({
  795. // ===== 已删除:user_id - 后端从token解析 =====
  796. ai_conversation_id: 0, // 0表示获取对话列表
  797. business_type: 3 // 考试工坊类型
  798. })
  799. const endTime = performance.now()
  800. console.log('📋 移动端考试工坊历史记录API调用耗时: ' + (endTime - startTime).toFixed(2) + 'ms')
  801. console.log('📋 移动端历史记录列表响应:', response)
  802. if (response.statusCode === 200) {
  803. const directConversations = Array.isArray(response.data) ? response.data : []
  804. let conversations = [...directConversations]
  805. const fallbackResponse = await apis.getHistoryRecord({
  806. ai_conversation_id: 0
  807. })
  808. if (fallbackResponse.statusCode === 200 && Array.isArray(fallbackResponse.data)) {
  809. const inferredExamConversations = fallbackResponse.data.filter(isExamWorkshopConversation)
  810. const conversationMap = new Map()
  811. directConversations.concat(inferredExamConversations).forEach((conversation) => {
  812. if (!conversation?.id) return
  813. conversationMap.set(conversation.id, conversation)
  814. })
  815. conversations = Array.from(conversationMap.values()).sort((a, b) => {
  816. return Number(b.updated_at || 0) - Number(a.updated_at || 0)
  817. })
  818. }
  819. // 设置历史记录总数
  820. historyTotal.value = conversations.length
  821. // 转换后端数据为前端格式
  822. historyData.value = conversations.map(conversation => ({
  823. id: conversation.id,
  824. title: generateConversationTitle(conversation.exam_name || conversation.title || conversation.content),
  825. time: formatHistoryTime(conversation.updated_at),
  826. businessType: conversation.business_type,
  827. isActive: false,
  828. // 保存原始数据用于后续查询
  829. rawData: conversation
  830. }))
  831. // 高亮当前对话
  832. if (ai_conversation_id.value) {
  833. historyData.value.forEach(item => { item.isActive = item.id === ai_conversation_id.value })
  834. }
  835. console.log('✅ 移动端考试工坊历史记录列表已设置: ' + historyData.value.length + '条记录,总数: ' + historyTotal.value)
  836. } else {
  837. console.error('❌ 获取移动端历史记录列表失败:', response.statusCode)
  838. }
  839. } catch (error) {
  840. console.error('❌ 获取移动端历史记录列表失败:', error)
  841. } finally {
  842. isLoadingHistory.value = false
  843. }
  844. }
  845. // ============ 业务逻辑方法 ============
  846. // 选择功能方法
  847. const selectFunction = (functionType) => {
  848. selectedFunction.value = functionType;
  849. console.log("选择功能:", functionType);
  850. };
  851. // 选择项目类型方法
  852. const selectProjectType = (typeKey) => {
  853. selectedProjectType.value = typeKey;
  854. console.log("选择工程类型:", projectTypes[typeKey].name);
  855. // 自动更新试卷名称
  856. const projectTypeName = projectTypes[typeKey].name;
  857. examName.value = projectTypeName + '工程施工技术考核';
  858. // 同时更新当前试卷的标题
  859. if (currentExam.value) {
  860. currentExam.value.title = examName.value;
  861. }
  862. };
  863. // 验证试卷名称
  864. const validateExamName = () => {
  865. if (examName.value.length > 32) {
  866. examName.value = examName.value.slice(0, 32);
  867. }
  868. };
  869. // 验证总分
  870. const validateTotalScore = () => {
  871. if (totalScore.value > 1000) {
  872. totalScore.value = 1000;
  873. console.warn("试卷总分不能超过1000分");
  874. }
  875. if (totalScore.value < 1) {
  876. totalScore.value = 1;
  877. }
  878. };
  879. // 验证每题分数
  880. const validateScorePerQuestion = (type) => {
  881. if (type.scorePerQuestion > 99) {
  882. type.scorePerQuestion = 99;
  883. console.warn(`${type.name}每题分数不能超过99分`);
  884. }
  885. if (type.scorePerQuestion < 1) {
  886. type.scorePerQuestion = 1;
  887. }
  888. };
  889. // 验证题目数量
  890. const validateQuestionCount = (type) => {
  891. if (type.questionCount > 99) {
  892. type.questionCount = 99;
  893. console.warn(`${type.name}题目数量不能超过99题`);
  894. }
  895. if (type.questionCount < 0) {
  896. type.questionCount = 0;
  897. }
  898. };
  899. const adjustQuestionCount = (type, delta) => {
  900. type.questionCount = Number(type.questionCount || 0) + delta;
  901. validateQuestionCount(type);
  902. };
  903. // 清除设置
  904. const clearSettings = () => {
  905. // 根据当前选择的工程类型设置试卷名称
  906. const projectTypeName = projectTypes[selectedProjectType.value].name;
  907. examName.value = projectTypeName + '工程施工技术考核';
  908. totalScore.value = 100;
  909. questionTypes.value = [
  910. { name: "单选题", scorePerQuestion: 2, questionCount: 8, romanNumeral: "一" },
  911. { name: "判断题", scorePerQuestion: 2, questionCount: 5, romanNumeral: "二" },
  912. { name: "多选题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "三" },
  913. { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
  914. ];
  915. console.log("清除设置");
  916. };
  917. // 生成试卷
  918. const generateExam = async () => {
  919. if (!examName.value.trim()) {
  920. console.warn("请输入试卷名称");
  921. return;
  922. }
  923. if (examName.value.trim().length === 0) {
  924. console.warn("试卷名称不能为空");
  925. return;
  926. }
  927. // 检查总分是否超过限制
  928. if (totalScore.value > 1000) {
  929. console.warn("试卷总分不能超过1000分");
  930. return;
  931. }
  932. // 检查每题分数和题目数量是否超过限制
  933. for (const type of questionTypes.value) {
  934. if (type.scorePerQuestion > 99) {
  935. console.warn(`${type.name}每题分数不能超过99分`);
  936. return;
  937. }
  938. if (type.questionCount > 99) {
  939. console.warn(`${type.name}题目数量不能超过99题`);
  940. return;
  941. }
  942. }
  943. // 检查总分是否合理
  944. const calculatedScore = questionTypes.value.reduce((total, type) => {
  945. return total + (type.scorePerQuestion * type.questionCount);
  946. }, 0);
  947. if (calculatedScore !== totalScore.value) {
  948. showToast(`总分不匹配!`, 3000);
  949. return;
  950. }
  951. console.log("开始生成试卷:", {
  952. function: selectedFunction.value,
  953. projectType: projectTypes[selectedProjectType.value].name,
  954. examName: examName.value,
  955. totalScore: totalScore.value,
  956. questionTypes: questionTypes.value,
  957. pptContent: pptContentDescription.value
  958. });
  959. try {
  960. isGenerating.value = true;
  961. const mode = selectedFunction.value === 'ppt' ? 'ppt' : 'ai';
  962. const prompt = await fetchMobileExamPrompt(mode);
  963. console.log('发送给AI的考试生成提示词:', prompt);
  964. // 调用AI接口生成试卷 - 使用与PC端相同的接口
  965. const response = await apis.sendDeepseekMessage({
  966. // ===== 已删除:user_id - 后端从token解析 =====
  967. business_type: 3,
  968. message: prompt,
  969. exam_name: examName.value,
  970. ai_conversation_id: ai_conversation_id.value
  971. });
  972. if (response.statusCode === 200) {
  973. const aiReply = response.data.reply;
  974. const aiConversationId = response.data.ai_conversation_id;
  975. console.log('AI生成的考试试卷:', aiReply);
  976. console.log('AI对话ID:', aiConversationId);
  977. // 保存对话ID
  978. ai_conversation_id.value = aiConversationId;
  979. // 解析AI回复并生成试卷
  980. const generatedExam = parseAIExamResponse(aiReply);
  981. // 更新当前试卷数据
  982. currentExam.value = generatedExam;
  983. currentExam.value.title = examName.value;
  984. currentExam.value.totalScore = totalScore.value;
  985. currentTime.value = new Date().toLocaleString('zh-CN');
  986. // 试卷数据已通过AI接口自动保存到数据库
  987. console.log('✅ 试卷已通过AI接口保存到数据库');
  988. // 显示考试详情页
  989. showExamDetail.value = true;
  990. // 刷新历史记录,确保新生成的试卷详情可被立即查看
  991. await getHistoryRecordList();
  992. if (ai_conversation_id.value > 0) {
  993. historyData.value.forEach((item) => {
  994. item.isActive = item.id === ai_conversation_id.value;
  995. });
  996. }
  997. console.log('✅ 移动端试卷生成完成!');
  998. } else {
  999. throw new Error('AI接口调用失败');
  1000. }
  1001. } catch (error) {
  1002. console.error('生成试卷失败:', error);
  1003. } finally {
  1004. isGenerating.value = false;
  1005. }
  1006. };
  1007. // 从服务端获取移动端提示词
  1008. const fetchMobileExamPrompt = async (mode = 'ai') => {
  1009. const normalizedQuestionTypes = questionTypes.value.map(type => ({
  1010. name: type.name,
  1011. romanNumeral: type.romanNumeral,
  1012. questionCount: Number(type.questionCount) || 0,
  1013. scorePerQuestion: Number(type.scorePerQuestion) || 0,
  1014. }));
  1015. const payload = {
  1016. mode,
  1017. client: 'mobile',
  1018. projectType: projectTypes[selectedProjectType.value]?.name || '',
  1019. examTitle: examName.value,
  1020. totalScore: totalScore.value,
  1021. questionTypes: normalizedQuestionTypes,
  1022. pptContent: pptContentDescription.value || ''
  1023. };
  1024. try {
  1025. const response = await apis.buildExamPrompt(payload);
  1026. if (!response?.data?.prompt) {
  1027. throw new Error(response?.msg || '提示词构建失败');
  1028. }
  1029. return response.data.prompt;
  1030. } catch (error) {
  1031. console.error('获取移动端提示词失败:', error);
  1032. throw error;
  1033. }
  1034. };
  1035. // 解析AI考试回复
  1036. const extractExamDataFromContent = (content) => {
  1037. if (!content || typeof content !== 'string') {
  1038. throw new Error('历史内容为空');
  1039. }
  1040. const directMatch = content.match(/\{[\s\S]*\}/);
  1041. if (!directMatch) {
  1042. throw new Error('未找到可解析的试卷JSON');
  1043. }
  1044. return JSON.parse(directMatch[0]);
  1045. };
  1046. const parseAIExamResponse = (aiReply) => {
  1047. try {
  1048. const examData = extractExamDataFromContent(aiReply);
  1049. const normalizedExam = normalizeGeneratedExam(examData);
  1050. ensureQuestionInitialValues(normalizedExam);
  1051. return normalizedExam;
  1052. } catch (error) {
  1053. console.error('解析AI回复失败:', error);
  1054. // 返回默认试卷结构
  1055. return generateDefaultExam();
  1056. }
  1057. };
  1058. const getQuestionTypeConfig = (index, fallbackScore = 0) => ({
  1059. scorePerQuestion: Number(questionTypes.value[index]?.scorePerQuestion) || fallbackScore,
  1060. questionCount: Number(questionTypes.value[index]?.questionCount) || 0,
  1061. });
  1062. const normalizeOptions = (options = []) => {
  1063. if (!Array.isArray(options)) {
  1064. return [];
  1065. }
  1066. return options.map((option, index) => {
  1067. if (typeof option === 'string') {
  1068. return {
  1069. key: String.fromCharCode(65 + index),
  1070. text: option,
  1071. };
  1072. }
  1073. return {
  1074. key: option?.key || String.fromCharCode(65 + index),
  1075. text: option?.text || option?.content || option?.label || "",
  1076. };
  1077. });
  1078. };
  1079. const normalizeQuestions = (questions = [], sectionKey) => {
  1080. if (!Array.isArray(questions)) {
  1081. return [];
  1082. }
  1083. return questions.map((question = {}) => {
  1084. if (sectionKey === 'singleChoice') {
  1085. return {
  1086. text: question.text || question.question_text || "",
  1087. options: normalizeOptions(question.options),
  1088. selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
  1089. };
  1090. }
  1091. if (sectionKey === 'judge') {
  1092. return {
  1093. text: question.text || question.question_text || "",
  1094. selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
  1095. };
  1096. }
  1097. if (sectionKey === 'multiple') {
  1098. const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || [];
  1099. return {
  1100. text: question.text || question.question_text || "",
  1101. options: normalizeOptions(question.options),
  1102. selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
  1103. };
  1104. }
  1105. return {
  1106. text: question.text || question.question_text || "",
  1107. outline: question.outline || question.answer_outline || {
  1108. keyFactors: question.answer || "请参考相关教材和标准规范",
  1109. measures: "请结合实际工程案例进行解答"
  1110. },
  1111. };
  1112. });
  1113. };
  1114. const normalizeSection = (rawSection, sectionKey, index, fallbackScore = 0) => {
  1115. const section = rawSection || {};
  1116. const config = getQuestionTypeConfig(index, fallbackScore);
  1117. const sourceQuestions = Array.isArray(section)
  1118. ? section
  1119. : (section.questions || section.items || section.question_list || []);
  1120. const normalizedQuestions = normalizeQuestions(sourceQuestions, sectionKey);
  1121. const count = Number(section.count ?? section.question_count ?? normalizedQuestions.length ?? config.questionCount) || 0;
  1122. const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? config.scorePerQuestion) || 0;
  1123. const totalScore = Number(section.totalScore ?? section.total_score ?? (scorePerQuestion * count)) || 0;
  1124. return {
  1125. scorePerQuestion,
  1126. totalScore,
  1127. count,
  1128. questions: normalizedQuestions,
  1129. };
  1130. };
  1131. const normalizeGeneratedExam = (examData = {}) => {
  1132. const normalizedExam = {
  1133. title: examData.title || examData.exam_name || examName.value,
  1134. totalScore: Number(examData.totalScore ?? examData.total_score ?? totalScore.value) || 0,
  1135. totalQuestions: Number(examData.totalQuestions ?? examData.total_questions) || 0,
  1136. singleChoice: normalizeSection(examData.singleChoice || examData.questions?.single_choice || examData.single_choice, 'singleChoice', 0, 2),
  1137. judge: normalizeSection(examData.judge || examData.questions?.judge, 'judge', 1, 2),
  1138. multiple: normalizeSection(examData.multiple || examData.questions?.multiple, 'multiple', 2, 3),
  1139. short: normalizeSection(examData.short || examData.questions?.short, 'short', 3, 10),
  1140. };
  1141. if (!normalizedExam.totalQuestions) {
  1142. normalizedExam.totalQuestions =
  1143. normalizedExam.singleChoice.count +
  1144. normalizedExam.judge.count +
  1145. normalizedExam.multiple.count +
  1146. normalizedExam.short.count;
  1147. }
  1148. return normalizedExam;
  1149. };
  1150. // 确保题目初始值正确
  1151. const ensureQuestionInitialValues = (examData) => {
  1152. // 确保单选题有正确的答案格式
  1153. if (examData.singleChoice && examData.singleChoice.questions) {
  1154. examData.singleChoice.questions.forEach(question => {
  1155. if (!question.selectedAnswer) {
  1156. question.selectedAnswer = question.options && question.options.length > 0 ? question.options[0].key : 'A';
  1157. }
  1158. });
  1159. }
  1160. // 确保判断题有正确答案
  1161. if (examData.judge && examData.judge.questions) {
  1162. examData.judge.questions.forEach(question => {
  1163. if (!question.selectedAnswer) {
  1164. question.selectedAnswer = Math.random() > 0.5 ? '正确' : '错误';
  1165. }
  1166. });
  1167. }
  1168. // 确保多选题有正确答案
  1169. if (examData.multiple && examData.multiple.questions) {
  1170. examData.multiple.questions.forEach(question => {
  1171. if (!question.selectedAnswers || !Array.isArray(question.selectedAnswers)) {
  1172. question.selectedAnswers = question.options && question.options.length > 1 ? [question.options[0].key, question.options[1].key] : [];
  1173. }
  1174. });
  1175. }
  1176. // 确保简答题有提纲
  1177. if (examData.short && examData.short.questions) {
  1178. examData.short.questions.forEach(question => {
  1179. if (!question.outline) {
  1180. question.outline = {
  1181. keyFactors: "请参考相关教材和标准规范",
  1182. measures: "请结合实际工程案例进行解答"
  1183. };
  1184. }
  1185. });
  1186. }
  1187. };
  1188. // 生成默认考试结构
  1189. const generateDefaultExam = () => {
  1190. return {
  1191. title: examName.value,
  1192. totalScore: totalScore.value,
  1193. totalQuestions: questionTypes.value.reduce((total, type) => total + type.questionCount, 0),
  1194. singleChoice: {
  1195. scorePerQuestion: questionTypes.value[0].scorePerQuestion,
  1196. totalScore: questionTypes.value[0].scorePerQuestion * questionTypes.value[0].questionCount,
  1197. count: questionTypes.value[0].questionCount,
  1198. questions: []
  1199. },
  1200. judge: {
  1201. scorePerQuestion: questionTypes.value[1].scorePerQuestion,
  1202. totalScore: questionTypes.value[1].scorePerQuestion * questionTypes.value[1].questionCount,
  1203. count: questionTypes.value[1].questionCount,
  1204. questions: []
  1205. },
  1206. multiple: {
  1207. scorePerQuestion: questionTypes.value[2].scorePerQuestion,
  1208. totalScore: questionTypes.value[2].scorePerQuestion * questionTypes.value[2].questionCount,
  1209. count: questionTypes.value[2].questionCount,
  1210. questions: []
  1211. },
  1212. short: {
  1213. scorePerQuestion: questionTypes.value[3].scorePerQuestion,
  1214. totalScore: questionTypes.value[3].scorePerQuestion * questionTypes.value[3].questionCount,
  1215. count: questionTypes.value[3].questionCount,
  1216. questions: []
  1217. }
  1218. }
  1219. };
  1220. const restoreExamFromHistory = (examData) => {
  1221. const exam = examData?.exam || examData || {}
  1222. const normalizedExam = normalizeGeneratedExam(exam)
  1223. examName.value = normalizedExam.title || examName.value
  1224. totalScore.value = normalizedExam.totalScore || totalScore.value
  1225. questionTypes.value = [
  1226. { name: "单选题", scorePerQuestion: normalizedExam.singleChoice.scorePerQuestion, questionCount: normalizedExam.singleChoice.count, romanNumeral: "一" },
  1227. { name: "判断题", scorePerQuestion: normalizedExam.judge.scorePerQuestion, questionCount: normalizedExam.judge.count, romanNumeral: "二" },
  1228. { name: "多选题", scorePerQuestion: normalizedExam.multiple.scorePerQuestion, questionCount: normalizedExam.multiple.count, romanNumeral: "三" },
  1229. { name: "简答题", scorePerQuestion: normalizedExam.short.scorePerQuestion, questionCount: normalizedExam.short.count, romanNumeral: "四" },
  1230. ]
  1231. currentExam.value = normalizedExam
  1232. }
  1233. // 返回配置页面
  1234. const backToConfig = () => {
  1235. showExamDetail.value = false;
  1236. };
  1237. // 展开/收起题型
  1238. const toggleSection = (sectionType) => {
  1239. expandedSections.value[sectionType] = !expandedSections.value[sectionType];
  1240. };
  1241. // 刷新题目 - 使用PC端的实现方式
  1242. const refreshQuestion = async (questionType, index) => {
  1243. try {
  1244. console.log(`刷新${questionType}类型第${index + 1}题`);
  1245. // 设置刷新状态
  1246. const key = `${questionType}_${index}`;
  1247. isRefreshing.value[key] = true;
  1248. // 构建单题重新生成的提示词
  1249. const prompt = buildSingleQuestionPrompt(questionType, index);
  1250. // 第一步:调用 /re_produce_single_question 接口,AI只生成题目
  1251. const response = await apis.reProduceSingleQuestion({
  1252. message: prompt
  1253. });
  1254. if (response.statusCode === 200) {
  1255. const aiReply = response.data.reply;
  1256. console.log('AI重新生成的题目:', aiReply);
  1257. // 解析AI回复并更新题目
  1258. const newQuestion = parseSingleQuestionResponse(aiReply, questionType);
  1259. console.log('解析后的新题目:', newQuestion);
  1260. if (newQuestion) {
  1261. updateQuestion(questionType, index, newQuestion);
  1262. console.log('准备保存到后端,对话ID:', ai_conversation_id.value);
  1263. // 第二步:使用 /re_modify_question 接口保存修改
  1264. await saveToReModifyQuestion(questionType, index, newQuestion);
  1265. showToast('题目重新生成成功!');
  1266. // AI回复完成后,获取最新的历史记录
  1267. await getHistoryRecordList();
  1268. // 如果是新对话,将最新的历史记录设为激活状态
  1269. if (ai_conversation_id.value > 0) {
  1270. historyData.value.forEach((item) => {
  1271. item.isActive = item.id === ai_conversation_id.value;
  1272. });
  1273. console.log('设置最新历史记录为激活状态,conversationId:', ai_conversation_id.value);
  1274. }
  1275. } else {
  1276. throw new Error('解析新题目失败');
  1277. }
  1278. } else {
  1279. throw new Error('AI接口调用失败');
  1280. }
  1281. } catch (error) {
  1282. console.error('刷新题目失败:', error);
  1283. showToast('重新生成题目失败,请重试');
  1284. } finally {
  1285. setTimeout(() => {
  1286. const key = `${questionType}_${index}`;
  1287. isRefreshing.value[key] = false;
  1288. }, 1000);
  1289. }
  1290. };
  1291. // 控制下载菜单显示/隐藏
  1292. const toggleDownloadMenu = () => {
  1293. if (!isGenerating.value) {
  1294. showDownloadMenu.value = !showDownloadMenu.value;
  1295. }
  1296. };
  1297. // 关闭下载菜单
  1298. const closeDownloadMenu = () => {
  1299. showDownloadMenu.value = false;
  1300. };
  1301. // 点击外部区域关闭下载菜单
  1302. const handleClickOutside = (event) => {
  1303. const dropdown = event.target.closest('.download-dropdown');
  1304. if (!dropdown) {
  1305. showDownloadMenu.value = false;
  1306. }
  1307. };
  1308. // 导出Word(有答案)
  1309. const exportToWordWithAnswers = async () => {
  1310. try {
  1311. closeDownloadMenu(); // 关闭下拉菜单
  1312. isGenerating.value = true;
  1313. console.log('开始导出Word格式试卷(有答案)...');
  1314. // 使用PC端的模拟Word导出功能
  1315. await simulateWordExport(true);
  1316. } catch (error) {
  1317. console.error('导出考试文件失败:', error);
  1318. showToast('导出失败,请重试');
  1319. } finally {
  1320. isGenerating.value = false;
  1321. }
  1322. };
  1323. // 导出Word(无答案)
  1324. const exportToWordWithoutAnswers = async () => {
  1325. try {
  1326. closeDownloadMenu(); // 关闭下拉菜单
  1327. isGenerating.value = true;
  1328. console.log('开始导出Word格式试卷(无答案)...');
  1329. // 使用PC端的模拟Word导出功能
  1330. await simulateWordExport(false);
  1331. } catch (error) {
  1332. console.error('导出考试文件失败:', error);
  1333. showToast('导出失败,请重试');
  1334. } finally {
  1335. isGenerating.value = false;
  1336. }
  1337. };
  1338. // 导出文件为Word格式(保留原函数以兼容其他可能的调用)
  1339. const exportToWord = async () => {
  1340. // 默认导出有答案版本
  1341. await exportToWordWithAnswers();
  1342. };
  1343. // 模拟Word导出功能(使用PC端实现)
  1344. const simulateWordExport = async (includeAnswers = true) => {
  1345. try {
  1346. // 创建Word文档内容(使用HTML格式,兼容WPS和Word)
  1347. const wordContent = createHTMLContent(currentExam.value, includeAnswers);
  1348. // 创建Blob对象 - 使用HTML格式
  1349. const blob = new Blob([wordContent], {
  1350. type: 'application/msword'
  1351. });
  1352. // 下载文件
  1353. const url = URL.createObjectURL(blob);
  1354. const link = document.createElement('a');
  1355. const fileName = includeAnswers
  1356. ? `${currentExam.value.title}_有答案_${currentTime.value.replace(/[:\s]/g, '_')}.doc`
  1357. : `${currentExam.value.title}_无答案_${currentTime.value.replace(/[:\s]/g, '_')}.doc`;
  1358. link.setAttribute('href', url);
  1359. link.setAttribute('download', fileName);
  1360. link.style.visibility = 'hidden';
  1361. document.body.appendChild(link);
  1362. link.click();
  1363. document.body.removeChild(link);
  1364. showToast(`导出成功${includeAnswers ? '(含答案)' : '(不含答案)'}`);
  1365. } catch (error) {
  1366. console.error('模拟Word导出失败:', error);
  1367. showToast('Word导出失败,请稍后重试');
  1368. }
  1369. };
  1370. // 创建HTML格式的Word文档内容(兼容WPS和Word)- 使用PC端实现
  1371. const createHTMLContent = (examData, includeAnswers = true) => {
  1372. const exam = currentExam.value;
  1373. // HTML文档内容,使用Word兼容的格式
  1374. let htmlContent = `<!DOCTYPE html>
  1375. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  1376. xmlns:w="urn:schemas-microsoft-com:office:word"
  1377. xmlns="http://www.w3.org/TR/REC-html40">
  1378. <head>
  1379. <meta charset="utf-8">
  1380. <meta name="ProgId" content="Word.Document">
  1381. <meta name="Generator" content="Microsoft Word 15">
  1382. <meta name="Originator" content="Microsoft Word 15">
  1383. <title>${exam.title || '试卷'}</title>
  1384. <!--[if gte mso 9]>
  1385. <xml>
  1386. <w:WordDocument>
  1387. <w:View>Print</w:View>
  1388. <w:Zoom>100</w:Zoom>
  1389. <w:DoNotPromptForConvert/>
  1390. <w:DoNotShowRevisions/>
  1391. <w:DoNotPrintRevisions/>
  1392. <w:DoNotShowComments/>
  1393. <w:DoNotShowInsertionsAndDeletions/>
  1394. <w:DoNotShowPropertyChanges/>
  1395. <w:Compatibility>
  1396. <w:BreakWrappedTables/>
  1397. <w:SnapToGridInCell/>
  1398. <w:WrapTextWithPunct/>
  1399. <w:UseAsianBreakRules/>
  1400. <w:DontGrowAutofit/>
  1401. </w:Compatibility>
  1402. </w:WordDocument>
  1403. </xml>
  1404. <![endif]-->
  1405. <style>
  1406. body {
  1407. font-family: "Microsoft YaHei", "宋体", Arial, sans-serif;
  1408. font-size: 14px;
  1409. line-height: 1.6;
  1410. margin: 24px;
  1411. color: #000;
  1412. }
  1413. .header {
  1414. text-align: center;
  1415. margin-bottom: 14px;
  1416. }
  1417. .exam-title {
  1418. font-size: 24px;
  1419. font-weight: bold;
  1420. margin-bottom: 14px;
  1421. color: #000;
  1422. }
  1423. .exam-info {
  1424. font-size: 14px;
  1425. color: #666;
  1426. margin-bottom: 14px;
  1427. }
  1428. .section {
  1429. margin-bottom: 14px;
  1430. }
  1431. .section-title {
  1432. font-size: 18px;
  1433. font-weight: bold;
  1434. margin-bottom: 14px;
  1435. color: #000;
  1436. border-bottom: 2px solid #3e7bfa;
  1437. padding-bottom: 5px;
  1438. }
  1439. .question {
  1440. margin-bottom: 14px;
  1441. padding: 10px;
  1442. background-color: #f9f9f9;
  1443. border-left: 4px solid #3e7bfa;
  1444. }
  1445. .question-header {
  1446. display: flex;
  1447. align-items: flex-start;
  1448. gap: 8px;
  1449. margin-bottom: 14px;
  1450. }
  1451. .question-number {
  1452. font-weight: bold;
  1453. color: #3e7bfa;
  1454. flex-shrink: 0;
  1455. }
  1456. .question-text {
  1457. flex: 1;
  1458. }
  1459. .options {
  1460. margin-left: 12px;
  1461. }
  1462. .option {
  1463. margin-bottom: 5px;
  1464. }
  1465. .answer {
  1466. margin-top: 10px;
  1467. padding: 8px;
  1468. background: #e8f4fd;
  1469. border-left: 3px solid #3e7bfa;
  1470. font-weight: bold;
  1471. color: #0066cc;
  1472. }
  1473. .outline-section {
  1474. margin: 10px 0;
  1475. padding: 8px;
  1476. background: #f0f8ff;
  1477. border-radius: 4px;
  1478. }
  1479. </style>
  1480. </head>
  1481. <body>
  1482. <div class="header">
  1483. <div class="exam-title">${exam.title || '考试试卷'}</div>
  1484. <div class="exam-info">
  1485. 总分:${exam.totalScore || 0}分 | 总题数:${exam.totalQuestions || 0}题 | 生成时间:${currentTime.value}
  1486. </div>
  1487. </div>`;
  1488. // 单选题
  1489. if (exam.singleChoice && exam.singleChoice.questions.length > 0) {
  1490. htmlContent += `
  1491. <div class="section">
  1492. <div class="section-title">一、单选题(${exam.singleChoice.count}题,每题${exam.singleChoice.scorePerQuestion}分,共${exam.singleChoice.totalScore}分)</div>`;
  1493. exam.singleChoice.questions.forEach((question, index) => {
  1494. htmlContent += `
  1495. <div class="question">
  1496. <div class="question-header">
  1497. <span class="question-number">${index + 1}.</span>
  1498. <span class="question-text">${question.text}</span>
  1499. </div>
  1500. <div class="options">`;
  1501. question.options.forEach(option => {
  1502. htmlContent += `
  1503. <div class="option">${option.key}. ${option.text}</div>`;
  1504. });
  1505. htmlContent += `
  1506. </div>
  1507. ${includeAnswers ? `<div class="answer">正确答案:${question.selectedAnswer}</div>` : ''}
  1508. </div>`;
  1509. });
  1510. htmlContent += `
  1511. </div>`;
  1512. }
  1513. // 判断题
  1514. if (exam.judge && exam.judge.questions.length > 0) {
  1515. htmlContent += `
  1516. <div class="section">
  1517. <div class="section-title">二、判断题(${exam.judge.count}题,每题${exam.judge.scorePerQuestion}分,共${exam.judge.totalScore}分)</div>`;
  1518. exam.judge.questions.forEach((question, index) => {
  1519. htmlContent += `
  1520. <div class="question">
  1521. <div class="question-header">
  1522. <span class="question-number">${index + 1}.</span>
  1523. <span class="question-text">${question.text}</span>
  1524. </div>
  1525. ${includeAnswers ? `<div class="answer">正确答案:${question.selectedAnswer}</div>` : ''}
  1526. </div>`;
  1527. });
  1528. htmlContent += `
  1529. </div>`;
  1530. }
  1531. // 多选题
  1532. if (exam.multiple && exam.multiple.questions.length > 0) {
  1533. htmlContent += `
  1534. <div class="section">
  1535. <div class="section-title">三、多选题(${exam.multiple.count}题,每题${exam.multiple.scorePerQuestion}分,共${exam.multiple.totalScore}分)</div>`;
  1536. exam.multiple.questions.forEach((question, index) => {
  1537. htmlContent += `
  1538. <div class="question">
  1539. <div class="question-header">
  1540. <span class="question-number">${index + 1}.</span>
  1541. <span class="question-text">${question.text}</span>
  1542. </div>
  1543. <div class="options">`;
  1544. question.options.forEach(option => {
  1545. htmlContent += `
  1546. <div class="option">${option.key}. ${option.text}</div>`;
  1547. });
  1548. htmlContent += `
  1549. </div>
  1550. ${includeAnswers ? `<div class="answer">正确答案:${(question.selectedAnswers || []).join(', ')}</div>` : ''}
  1551. </div>`;
  1552. });
  1553. htmlContent += `
  1554. </div>`;
  1555. }
  1556. // 简答题
  1557. if (exam.short && exam.short.questions.length > 0) {
  1558. htmlContent += `
  1559. <div class="section">
  1560. <div class="section-title">四、简答题(${exam.short.count}题,每题${exam.short.scorePerQuestion}分,共${exam.short.totalScore}分)</div>`;
  1561. exam.short.questions.forEach((question, index) => {
  1562. htmlContent += `
  1563. <div class="question">
  1564. <div class="question-header">
  1565. <span class="question-number">${index + 1}.</span>
  1566. <span class="question-text">${question.text}</span>
  1567. </div>`;
  1568. if (question.outline && includeAnswers) {
  1569. htmlContent += `
  1570. <div class="outline-section">
  1571. <strong>关键要点:</strong>${cleanText(question.outline.keyFactors)}
  1572. </div>
  1573. <div class="outline-section">
  1574. <strong>具体措施:</strong>${cleanText(question.outline.measures)}
  1575. </div>`;
  1576. }
  1577. htmlContent += `
  1578. </div>`;
  1579. });
  1580. htmlContent += `
  1581. </div>`;
  1582. }
  1583. htmlContent += `
  1584. </body>
  1585. </html>`;
  1586. return htmlContent;
  1587. };
  1588. // 创建Word格式的考试文档内容(保留原函数以防其他地方使用)
  1589. const createExamWordContent = (examData) => {
  1590. const currentTime = new Date().toLocaleString('zh-CN');
  1591. let htmlContent = `<!DOCTYPE html>
  1592. <html xmlns:o="urn:schemas-microsoft-com:office:office"
  1593. xmlns:w="urn:schemas-microsoft-com:office:word"
  1594. xmlns="http://www.w3.org/TR/REC-html40">
  1595. <head>
  1596. <meta charset="utf-8">
  1597. <meta name="ProgId" content="Word.Document">
  1598. <meta name="Generator" content="Microsoft Word">
  1599. <meta name="Originator" content="Microsoft Word">
  1600. <meta name="ViewMode" content="PrintLayout">
  1601. <meta name="Zoom" content="100">
  1602. <meta name="DocumentProperties" content="false">
  1603. <meta name="DocumentSecurity" content="false">
  1604. <meta name="DocumentProtection" content="false">
  1605. <meta name="DocumentView" content="PrintLayout">
  1606. <title>${examData.title}</title>
  1607. <style>
  1608. body { font-family: '微软雅黑', 'Microsoft YaHei', sans-serif; line-height: 1.6; margin: 20px; }
  1609. .header { text-align: center; margin-bottom: 30px; }
  1610. .exam-title { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
  1611. .exam-info { font-size: 14px; color: #666; margin-bottom: 30px; }
  1612. .section { margin-bottom: 25px; }
  1613. .section-title { font-size: 16px; font-weight: bold; margin-bottom: 15px; }
  1614. .question { margin-bottom: 20px; padding: 10px; }
  1615. .question-header { font-weight: bold; margin-bottom: 10px; }
  1616. .option { margin: 5px 0; }
  1617. .answer { color: #0066cc; font-weight: bold; margin-top: 10px; }
  1618. .outline-section { margin: 10px 0; }
  1619. </style>
  1620. </head>
  1621. <body>
  1622. <div class="header">
  1623. <div class="exam-title">${examData.title || '考试试卷'}</div>
  1624. <div class="exam-info">
  1625. 总分:${examData.totalScore || 0}分 | 总题数:${examData.totalQuestions || 0}题 | 生成时间:${currentTime}
  1626. </div>
  1627. </div>`;
  1628. // 单选题
  1629. if (examData.singleChoice && examData.singleChoice.questions.length > 0) {
  1630. htmlContent += `
  1631. <div class="section">
  1632. <div class="section-title">一、单选题(${examData.singleChoice.count}题,每题${examData.singleChoice.scorePerQuestion}分,共${examData.singleChoice.totalScore}分)</div>`;
  1633. examData.singleChoice.questions.forEach((question, index) => {
  1634. htmlContent += `
  1635. <div class="question">
  1636. <div class="question-header">
  1637. <span class="question-number">${index + 1}.</span> ${question.text}
  1638. </div>
  1639. <div class="options">`;
  1640. question.options.forEach(option => {
  1641. htmlContent += `
  1642. <div class="option">${option.key}. ${option.text}</div>`;
  1643. });
  1644. htmlContent += `
  1645. </div>
  1646. <div class="answer">正确答案:${question.selectedAnswer} </div>
  1647. </div>`;
  1648. });
  1649. htmlContent += `
  1650. </div>`;
  1651. }
  1652. // 判断题
  1653. if (examData.judge && examData.judge.questions.length > 0) {
  1654. htmlContent += `
  1655. <div class="section">
  1656. <div class="section-title">二、判断题(${examData.judge.count}题,每题${examData.judge.scorePerQuestion}分,共${examData.judge.totalScore}分)</div>`;
  1657. examData.judge.questions.forEach((question, index) => {
  1658. htmlContent += `
  1659. <div class="question">
  1660. <div class="question-header">
  1661. <span class="question-number">${index + 1}.</span> ${question.text}
  1662. </div>
  1663. <div class="answer">正确答案:${question.selectedAnswer} </div>
  1664. </div>`;
  1665. });
  1666. htmlContent += `
  1667. </div>`;
  1668. }
  1669. // 多选题
  1670. if (examData.multiple && examData.multiple.questions.length > 0) {
  1671. htmlContent += `
  1672. <div class="section">
  1673. <div class="section-title">三、多选题(${examData.multiple.count}题,每题${examData.multiple.scorePerQuestion}分,共${examData.multiple.totalScore}分)</div>`;
  1674. examData.multiple.questions.forEach((question, index) => {
  1675. htmlContent += `
  1676. <div class="question">
  1677. <div class="question-header">
  1678. <span class="question-number">${index + 1}.</span> ${question.text}
  1679. </div>
  1680. <div class="options">`;
  1681. question.options.forEach(option => {
  1682. htmlContent += `
  1683. <div class="option">${option.key}. ${option.text}</div>`;
  1684. });
  1685. htmlContent += `
  1686. </div>
  1687. <div class="answer">正确答案:${(question.selectedAnswers || []).join(', ')} </div>
  1688. </div>`;
  1689. });
  1690. htmlContent += `
  1691. </div>`;
  1692. }
  1693. // 简答题
  1694. if (examData.short && examData.short.questions.length > 0) {
  1695. htmlContent += `
  1696. <div class="section">
  1697. <div class="section-title">四、简答题(${examData.short.count}题,每题${examData.short.scorePerQuestion}分,共${examData.short.totalScore}分)</div>`;
  1698. examData.short.questions.forEach((question, index) => {
  1699. htmlContent += `
  1700. <div class="question">
  1701. <div class="question-header">
  1702. <span class="question-number">${index + 1}.</span> ${question.text}
  1703. </div>`;
  1704. if (question.outline) {
  1705. htmlContent += `
  1706. <div class="outline-section">
  1707. <strong>关键要点:</strong>${question.outline.keyFactors}
  1708. </div>
  1709. <div class="outline-section">
  1710. <strong>具体措施:</strong>${question.outline.measures}
  1711. </div>`;
  1712. }
  1713. htmlContent += `
  1714. </div>`;
  1715. });
  1716. htmlContent += `
  1717. </div>`;
  1718. }
  1719. htmlContent += `
  1720. </body>
  1721. </html>`;
  1722. return htmlContent;
  1723. };
  1724. // 文件上传相关方法
  1725. const triggerFileUpload = () => {
  1726. fileInput.value.click();
  1727. };
  1728. const handleFileSelect = async (event) => {
  1729. const file = event.target.files[0];
  1730. if (!file) return;
  1731. // 检查文件大小(20MB限制)
  1732. const maxSize = 20 * 1024 * 1024; // 20MB
  1733. if (file.size > maxSize) {
  1734. console.error('文件大小不能超过20MB');
  1735. return;
  1736. }
  1737. // 检查文件类型
  1738. const allowedTypes = ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'];
  1739. if (!allowedTypes.includes(file.type)) {
  1740. console.error('请选择PPT文件(.ppt/.pptx)');
  1741. return;
  1742. }
  1743. try {
  1744. selectedFile.value = {
  1745. name: file.name,
  1746. size: file.size,
  1747. icon: '📋'
  1748. };
  1749. // 读取PPT内容
  1750. const pptContent = await readPPTFile(file);
  1751. pptContentDescription.value = pptContent;
  1752. // 自动更新试卷名称(使用文件名,去掉扩展名)
  1753. const fileName = file.name.replace(/\.[^/.]+$/, '');
  1754. examName.value = fileName + '培训考核';
  1755. console.log('✅ PPT文件上传成功:', file.name);
  1756. } catch (error) {
  1757. console.error('PPT文件处理失败:', error);
  1758. selectedFile.value = null;
  1759. }
  1760. };
  1761. const removeSelectedFile = () => {
  1762. selectedFile.value = null;
  1763. pptContentDescription.value = '';
  1764. fileInput.value.value = '';
  1765. // 恢复默认试卷名称
  1766. const projectTypeName = projectTypes[selectedProjectType.value].name;
  1767. examName.value = projectTypeName + '工程施工技术考核';
  1768. };
  1769. // 读取PPT文件的文本内容
  1770. const readPPTFile = async (file) => {
  1771. return new Promise((resolve, reject) => {
  1772. const reader = new FileReader();
  1773. reader.onload = async (e) => {
  1774. try {
  1775. console.log('开始解析PPT文件...');
  1776. // 这里使用JSZip来解压PPTX文件并提取文本
  1777. // 注意:这是一个简化版本的实现
  1778. const arrayBuffer = e.target.result;
  1779. // 提取第一页的文本内容作为示例
  1780. const content = await extractTextFromPPT(arrayBuffer);
  1781. resolve(content);
  1782. } catch (error) {
  1783. console.error('PPT解析失败:', error);
  1784. reject(error);
  1785. }
  1786. };
  1787. reader.onerror = () => {
  1788. reject(new Error('文件读取失败'));
  1789. };
  1790. reader.readAsArrayBuffer(file);
  1791. });
  1792. };
  1793. // 从PPT提取文本(简化版本)
  1794. const extractTextFromPPT = async (arrayBuffer) => {
  1795. // 这是一个简化的实现,实际项目中需要使用专门的PPT解析库
  1796. return "提取的文本内容:" +
  1797. "PPT培训课件主要包含以下内容:" +
  1798. "1. 安全培训概述" +
  1799. "- 培训目标和意义" +
  1800. "- 培训对象和要求" +
  1801. "- 培训计划和安排" +
  1802. "2. 基础知识" +
  1803. "- 安全规章制度" +
  1804. "- 危险源识别" +
  1805. "- 应急处理方法" +
  1806. "3. 操作技能" +
  1807. "- 标准化操作流程" +
  1808. "- 安全操作规范" +
  1809. "- 事故预防措施" +
  1810. "4. 考核要求" +
  1811. "- 理论知识考核" +
  1812. "- 实操技能考核" +
  1813. "- 综合评估标准" +
  1814. "此PPT内容涵盖了安全培训的各个方面,适合制作综合性的考试题目。";
  1815. };
  1816. // 格式化文件大小
  1817. const formatFileSize = (bytes) => {
  1818. if (bytes === 0) return '0 B';
  1819. const k = 1024;
  1820. const sizes = ['B', 'KB', 'MB', 'GB'];
  1821. const i = Math.floor(Math.log(bytes) / Math.log(k));
  1822. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  1823. };
  1824. // 清理文本中的中括号和双引号
  1825. const cleanText = (text) => {
  1826. if (!text) return '';
  1827. return text.toString()
  1828. .replace(/[\[\]]/g, '') // 移除中括号
  1829. .replace(/[""]/g, '') // 移除双引号
  1830. .replace(/['']/g, '') // 移除单引号
  1831. .trim();
  1832. };
  1833. // ============ PC端辅助函数(用于刷新题目) ============
  1834. // 构建单题重新生成的提示词
  1835. const buildSingleQuestionPrompt = (sectionType, questionIndex) => {
  1836. const projectType = projectTypes[selectedProjectType.value].name;
  1837. const questionType = getQuestionTypeName(sectionType);
  1838. const scorePerQuestion = getQuestionScore(sectionType);
  1839. // 获取当前题目作为参考
  1840. const currentQuestion = getCurrentQuestion(sectionType, questionIndex);
  1841. let prompt = `请基于以下${projectType}工程的${questionType}题目,重新生成一道相似主题的题目,要求如下:
  1842. 当前题目参考:
  1843. ${JSON.stringify(currentQuestion, null, 2)}
  1844. 题目类型:${questionType}
  1845. 每题分值:${scorePerQuestion}分
  1846. 题目序号:第${questionIndex + 1}题
  1847. 请严格按照以下JSON格式返回,不要包含任何其他文字:
  1848. `;
  1849. // 根据题目类型添加不同的格式要求
  1850. if (sectionType === 'single') {
  1851. prompt += `{
  1852. "text": "题目内容",
  1853. "options": [
  1854. {"key": "A", "text": "选项A"},
  1855. {"key": "B", "text": "选项B"},
  1856. {"key": "C", "text": "选项C"},
  1857. {"key": "D", "text": "选项D"}
  1858. ],
  1859. "selectedAnswer": "A"
  1860. }`;
  1861. } else if (sectionType === 'judge') {
  1862. prompt += `{
  1863. "text": "题目内容",
  1864. "selectedAnswer": "正确"
  1865. }`;
  1866. } else if (sectionType === 'multiple') {
  1867. prompt += `{
  1868. "text": "题目内容",
  1869. "options": [
  1870. {"key": "A", "text": "选项A"},
  1871. {"key": "B", "text": "选项B"},
  1872. {"key": "C", "text": "选项C"},
  1873. {"key": "D", "text": "选项D"}
  1874. ],
  1875. "selectedAnswers": ["A", "B"]
  1876. }`;
  1877. } else if (sectionType === 'short') {
  1878. prompt += `{
  1879. "text": "题目内容",
  1880. "outline": {
  1881. "keyFactors": "关键要点内容",
  1882. "measures": "具体措施内容"
  1883. }
  1884. }`;
  1885. }
  1886. return prompt;
  1887. };
  1888. // 获取题目类型名称
  1889. const getQuestionTypeName = (sectionType) => {
  1890. const typeMap = {
  1891. 'single': '单选题',
  1892. 'judge': '判断题',
  1893. 'multiple': '多选题',
  1894. 'short': '简答题'
  1895. };
  1896. return typeMap[sectionType] || sectionType;
  1897. };
  1898. // 获取题目分值
  1899. const getQuestionScore = (sectionType) => {
  1900. if (currentExam.value[sectionType]) {
  1901. return currentExam.value[sectionType].scorePerQuestion;
  1902. }
  1903. return 5; // 默认分值
  1904. };
  1905. // 获取当前题目
  1906. const getCurrentQuestion = (sectionType, questionIndex) => {
  1907. if (currentExam.value[sectionType] && currentExam.value[sectionType].questions) {
  1908. return currentExam.value[sectionType].questions[questionIndex];
  1909. }
  1910. return null;
  1911. };
  1912. // 解析单题AI回复
  1913. const parseSingleQuestionResponse = (aiReply, sectionType) => {
  1914. try {
  1915. console.log('AI回复内容:', aiReply);
  1916. console.log('题目类型:', sectionType);
  1917. // 尝试提取JSON内容
  1918. const jsonMatch = aiReply.match(/\{[\s\S]*\}/);
  1919. if (jsonMatch) {
  1920. const questionData = JSON.parse(jsonMatch[0]);
  1921. console.log('解析后的题目数据:', questionData);
  1922. // 如果是简答题,检查keyFactors字段
  1923. if (sectionType === 'short' && questionData.outline && questionData.outline.keyFactors) {
  1924. console.log('简答题keyFactors原始值:', questionData.outline.keyFactors);
  1925. // 如果keyFactors是数组,转换为字符串
  1926. if (Array.isArray(questionData.outline.keyFactors)) {
  1927. questionData.outline.keyFactors = questionData.outline.keyFactors.join(' ');
  1928. console.log('转换后的keyFactors:', questionData.outline.keyFactors);
  1929. }
  1930. }
  1931. return questionData;
  1932. } else {
  1933. console.error('未找到有效的JSON数据');
  1934. return null;
  1935. }
  1936. } catch (error) {
  1937. console.error('解析AI回复失败:', error);
  1938. return null;
  1939. }
  1940. };
  1941. // 更新题目
  1942. const updateQuestion = (sectionType, questionIndex, newQuestion) => {
  1943. let updatedQuestion;
  1944. if (sectionType === 'single') {
  1945. updatedQuestion = { ...newQuestion };
  1946. if (!updatedQuestion.selectedAnswer || updatedQuestion.selectedAnswer === "") {
  1947. updatedQuestion.selectedAnswer = updatedQuestion.options && updatedQuestion.options.length > 0 ? updatedQuestion.options[0].key : 'A';
  1948. }
  1949. currentExam.value.singleChoice.questions[questionIndex] = updatedQuestion;
  1950. } else if (sectionType === 'judge') {
  1951. updatedQuestion = { ...newQuestion };
  1952. if (!updatedQuestion.selectedAnswer || updatedQuestion.selectedAnswer === "") {
  1953. updatedQuestion.selectedAnswer = Math.random() > 0.5 ? '正确' : '错误';
  1954. }
  1955. currentExam.value.judge.questions[questionIndex] = updatedQuestion;
  1956. } else if (sectionType === 'multiple') {
  1957. updatedQuestion = { ...newQuestion };
  1958. if (!updatedQuestion.selectedAnswers || !Array.isArray(updatedQuestion.selectedAnswers)) {
  1959. updatedQuestion.selectedAnswers = updatedQuestion.options && updatedQuestion.options.length > 1 ? [updatedQuestion.options[0].key, updatedQuestion.options[1].key] : [];
  1960. }
  1961. currentExam.value.multiple.questions[questionIndex] = updatedQuestion;
  1962. } else if (sectionType === 'short') {
  1963. updatedQuestion = { ...newQuestion };
  1964. if (!updatedQuestion.outline) {
  1965. updatedQuestion.outline = {
  1966. keyFactors: "请参考相关教材和标准规范",
  1967. measures: "请结合实际工程案例进行解答"
  1968. };
  1969. }
  1970. currentExam.value.short.questions[questionIndex] = updatedQuestion;
  1971. }
  1972. console.log(`更新${sectionType}第${questionIndex + 1}题:`, updatedQuestion);
  1973. };
  1974. // 保存到reModifyQuestion接口
  1975. const saveToReModifyQuestion = async (sectionType, questionIndex, newQuestion) => {
  1976. console.log('对话id', ai_conversation_id.value);
  1977. try {
  1978. // 使用当前保存的对话ID
  1979. if (!ai_conversation_id.value) {
  1980. console.warn('没有找到对话ID,跳过保存');
  1981. return;
  1982. }
  1983. // 构建要保存的内容 - 保存整个试卷的JSON字符串
  1984. const content = JSON.stringify(currentExam.value);
  1985. console.log('保存到 /re_modify_question 的内容:', content);
  1986. // 调用后端接口保存修改
  1987. const response = await apis.reModifyQuestion({
  1988. ai_conversation_id: ai_conversation_id.value,
  1989. content: content
  1990. });
  1991. if (response.statusCode === 200) {
  1992. console.log('修改已保存到后端');
  1993. } else {
  1994. console.error('保存到后端失败:', response);
  1995. }
  1996. } catch (error) {
  1997. console.error('保存到后端失败:', error);
  1998. }
  1999. };
  2000. // 页面加载时不再自动加载历史记录,改为点击菜单时加载
  2001. onMounted(async () => {
  2002. try {
  2003. console.log('🚀 移动端考试工坊页面初始化完成')
  2004. // 初始化原生导航栏(子页面模式:返回按钮执行路由后退)
  2005. initNativeNavForSubPage(() => router.back())
  2006. } catch (error) {
  2007. console.error('❌ 移动端考试工坊页面初始化失败:', error)
  2008. }
  2009. })
  2010. // 监听历史记录抽屉显示状态,显示时加载数据
  2011. watch(showHistory, async (newVal) => {
  2012. if (newVal) {
  2013. console.log('📋 历史记录抽屉打开,开始刷新数据...')
  2014. await getHistoryRecordList()
  2015. }
  2016. })
  2017. </script>
  2018. <style lang="less" scoped>
  2019. .mobile-exam-workshop {
  2020. min-height: 100vh;
  2021. background: #EBF3FF;
  2022. font-family: "Alibaba PuHuiTi 3.0", sans-serif;
  2023. }
  2024. .mobile-content {
  2025. padding: 16px;
  2026. position: relative;
  2027. max-height: calc(100vh - 60px);
  2028. overflow-y: auto;
  2029. }
  2030. /* 考试工坊主界面样式 */
  2031. .exam-workshop-main {
  2032. .config-section {
  2033. background: white;
  2034. border-radius: 12px;
  2035. padding: 20px;
  2036. margin-bottom: 16px;
  2037. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  2038. .config-header {
  2039. display: flex;
  2040. align-items: center;
  2041. gap: 8px;
  2042. margin-bottom: 16px;
  2043. .step-number {
  2044. width: 28px;
  2045. height: 28px;
  2046. background: #3e7bfa;
  2047. color: white;
  2048. border-radius: 50%;
  2049. display: flex;
  2050. align-items: center;
  2051. justify-content: center;
  2052. font-size: 14px;
  2053. font-weight: 600;
  2054. }
  2055. h3 {
  2056. font-size: 20px;
  2057. font-weight: 600;
  2058. color: #1f2937;
  2059. margin: 0;
  2060. }
  2061. }
  2062. .type-cards {
  2063. .type-cards-row {
  2064. display: grid;
  2065. grid-template-columns: repeat(2, 1fr);
  2066. gap: 12px;
  2067. .type-card {
  2068. display: flex;
  2069. flex-direction: column;
  2070. align-items: center;
  2071. gap: 8px;
  2072. padding: 16px 12px;
  2073. border: 2px solid #e5e7eb;
  2074. border-radius: 12px;
  2075. background: white;
  2076. cursor: pointer;
  2077. transition: all 0.3s ease;
  2078. min-height: 80px;
  2079. justify-content: center;
  2080. &:hover {
  2081. border-color: #3e7bfa;
  2082. transform: translateY(-2px);
  2083. }
  2084. &.active {
  2085. border-color: #3e7bfa;
  2086. background: rgba(62, 123, 250, 0.1);
  2087. .type-icon {
  2088. filter: brightness(0) saturate(100%) invert(27%) sepia(51%)
  2089. saturate(2878%) hue-rotate(199deg) brightness(104%) contrast(97%);
  2090. }
  2091. span {
  2092. color: #3e7bfa;
  2093. }
  2094. }
  2095. .type-icon {
  2096. width: 32px;
  2097. height: 32px;
  2098. transition: filter 0.3s ease;
  2099. }
  2100. span {
  2101. font-size: 12px;
  2102. color: #374151;
  2103. font-weight: 500;
  2104. transition: color 0.3s ease;
  2105. text-align: center;
  2106. }
  2107. }
  2108. }
  2109. }
  2110. .generation-methods {
  2111. display: flex;
  2112. flex-direction: column;
  2113. gap: 12px;
  2114. .method-card {
  2115. display: flex;
  2116. align-items: flex-start;
  2117. gap: 12px;
  2118. padding: 16px;
  2119. border: 2px solid #e5e7eb;
  2120. border-radius: 12px;
  2121. background: white;
  2122. cursor: pointer;
  2123. transition: all 0.3s ease;
  2124. position: relative;
  2125. &:hover {
  2126. border-color: #3e7bfa;
  2127. }
  2128. &.active {
  2129. border-color: #3e7bfa;
  2130. background: rgba(62, 123, 250, 0.1);
  2131. .method-icon {
  2132. filter: brightness(0) saturate(100%) invert(27%) sepia(51%)
  2133. saturate(2878%) hue-rotate(199deg) brightness(104%) contrast(97%);
  2134. }
  2135. .method-content h4 {
  2136. color: #3e7bfa;
  2137. }
  2138. }
  2139. .method-icon {
  2140. width: 24px;
  2141. height: 24px;
  2142. flex-shrink: 0;
  2143. transition: filter 0.3s ease;
  2144. margin-top: 2px;
  2145. }
  2146. .method-content {
  2147. flex: 1;
  2148. h4 {
  2149. font-size: 14px;
  2150. font-weight: 600;
  2151. color: #1f2937;
  2152. margin: 0 0 4px 0;
  2153. transition: color 0.3s ease;
  2154. }
  2155. p {
  2156. font-size: 12px;
  2157. color: #6b7280;
  2158. margin: 0;
  2159. line-height: 1.4;
  2160. }
  2161. .ppt-file-preview {
  2162. margin-top: 8px;
  2163. .file-preview {
  2164. display: flex;
  2165. align-items: center;
  2166. background: #f9fafb;
  2167. border: 1px solid #e5e7eb;
  2168. border-radius: 8px;
  2169. padding: 8px;
  2170. .file-icon {
  2171. font-size: 16px;
  2172. margin-right: 8px;
  2173. width: 20px;
  2174. text-align: center;
  2175. }
  2176. .file-info {
  2177. flex: 1;
  2178. .file-name {
  2179. font-size: 11px;
  2180. font-weight: 500;
  2181. color: #374151;
  2182. overflow: hidden;
  2183. text-overflow: ellipsis;
  2184. white-space: nowrap;
  2185. max-width: 120px;
  2186. }
  2187. .file-size {
  2188. font-size: 10px;
  2189. color: #9ca3af;
  2190. }
  2191. }
  2192. .remove-file-btn {
  2193. background: none;
  2194. border: none;
  2195. cursor: pointer;
  2196. color: #6b7280;
  2197. padding: 2px;
  2198. .remove-icon {
  2199. font-size: 14px;
  2200. }
  2201. &:hover {
  2202. color: #ef4444;
  2203. }
  2204. }
  2205. }
  2206. }
  2207. }
  2208. }
  2209. }
  2210. .exam-config-container {
  2211. .config-main {
  2212. .config-form {
  2213. display: grid;
  2214. grid-template-columns: 2fr 1fr;
  2215. gap: 12px;
  2216. margin-bottom: 16px;
  2217. .form-group {
  2218. label {
  2219. display: block;
  2220. font-size: 16px;
  2221. font-weight: 600;
  2222. color: #374151;
  2223. margin-bottom: 6px;
  2224. }
  2225. .input-wrapper,
  2226. .score-input {
  2227. position: relative;
  2228. .config-input {
  2229. width: 100%;
  2230. height: 48px;
  2231. padding: 12px 50px 12px 16px;
  2232. border: 1px solid #d1d5db;
  2233. border-radius: 8px;
  2234. font-size: 16px !important;
  2235. background: white;
  2236. box-sizing: border-box;
  2237. line-height: 1.5;
  2238. &:focus {
  2239. outline: none;
  2240. border-color: #3e7bfa;
  2241. box-shadow: 0 0 0 2px rgba(62, 123, 250, 0.1);
  2242. }
  2243. &:disabled {
  2244. background: #f9fafb;
  2245. color: #9ca3af;
  2246. }
  2247. }
  2248. .char-count {
  2249. position: absolute;
  2250. right: 12px;
  2251. top: 50%;
  2252. transform: translateY(-50%);
  2253. font-size: 12px;
  2254. color: #6b7280;
  2255. font-weight: 500;
  2256. &.warning {
  2257. color: #f59e0b;
  2258. }
  2259. }
  2260. .unit {
  2261. position: absolute;
  2262. right: 12px;
  2263. top: 50%;
  2264. transform: translateY(-50%);
  2265. color: #6b7280;
  2266. font-size: 18px;
  2267. font-weight: 500;
  2268. }
  2269. }
  2270. .score-input {
  2271. .config-input {
  2272. padding-right: 50px;
  2273. }
  2274. }
  2275. }
  2276. }
  2277. .question-types-title {
  2278. font-size: 16px;
  2279. font-weight: 600;
  2280. color: #374151;
  2281. margin: 20px 0 12px 0;
  2282. }
  2283. .question-types {
  2284. .question-type {
  2285. background: #f9fafb;
  2286. border-radius: 8px;
  2287. padding: 12px;
  2288. margin-bottom: 8px;
  2289. .type-header {
  2290. display: flex;
  2291. align-items: center;
  2292. gap: 8px;
  2293. margin-bottom: 8px;
  2294. .type-name {
  2295. font-size: 14px;
  2296. font-weight: 600;
  2297. color: #374151;
  2298. white-space: nowrap;
  2299. }
  2300. .progress-bar {
  2301. flex: 1;
  2302. height: 4px;
  2303. background: #e5e7eb;
  2304. border-radius: 2px;
  2305. overflow: hidden;
  2306. .progress-fill {
  2307. height: 100%;
  2308. background: #3e7bfa;
  2309. transition: width 0.3s ease;
  2310. }
  2311. }
  2312. }
  2313. .score-config {
  2314. display: grid;
  2315. grid-template-columns: repeat(2, 1fr);
  2316. gap: 8px;
  2317. .config-item {
  2318. display: flex;
  2319. align-items: center;
  2320. gap: 4px;
  2321. font-size: 13px;
  2322. span {
  2323. color: #6b7280;
  2324. white-space: nowrap;
  2325. }
  2326. .score-input-field,
  2327. .count-input-field {
  2328. width: 50px;
  2329. padding: 6px 8px;
  2330. border: 1px solid #d1d5db;
  2331. border-radius: 4px;
  2332. font-size: 13px;
  2333. text-align: center;
  2334. &:focus {
  2335. outline: none;
  2336. border-color: #3e7bfa;
  2337. }
  2338. &:disabled {
  2339. background: #f3f4f6;
  2340. color: #9ca3af;
  2341. }
  2342. }
  2343. .count-stepper {
  2344. display: flex;
  2345. align-items: center;
  2346. gap: 6px;
  2347. }
  2348. .stepper-buttons {
  2349. display: flex;
  2350. flex-direction: column;
  2351. gap: 3px;
  2352. }
  2353. .stepper-btn {
  2354. width: 10px;
  2355. height: 7px;
  2356. padding: 0;
  2357. border: none;
  2358. outline: none;
  2359. appearance: none;
  2360. -webkit-appearance: none;
  2361. background: #3e7bfa;
  2362. display: block;
  2363. cursor: pointer;
  2364. transition: opacity 0.2s ease, transform 0.2s ease;
  2365. &:disabled {
  2366. opacity: 0.35;
  2367. cursor: not-allowed;
  2368. }
  2369. }
  2370. .stepper-btn-up {
  2371. clip-path: polygon(50% 0, 0 100%, 100% 100%);
  2372. }
  2373. .stepper-btn-down {
  2374. clip-path: polygon(0 0, 100% 0, 50% 100%);
  2375. }
  2376. }
  2377. }
  2378. }
  2379. }
  2380. }
  2381. .preview-panel {
  2382. background: #f8fafc;
  2383. border-radius: 8px;
  2384. padding: 16px;
  2385. margin-top: 16px;
  2386. border: 1px solid #e5e7eb;
  2387. .preview-header {
  2388. display: flex;
  2389. align-items: center;
  2390. gap: 6px;
  2391. margin-bottom: 12px;
  2392. .preview-icon {
  2393. width: 16px;
  2394. height: 16px;
  2395. }
  2396. h3 {
  2397. font-size: 16px;
  2398. font-weight: 600;
  2399. color: #374151;
  2400. margin: 0;
  2401. }
  2402. }
  2403. .preview-content {
  2404. .preview-title {
  2405. font-size: 16px;
  2406. font-weight: 600;
  2407. color: #1f2937;
  2408. margin: 0 0 12px 0;
  2409. line-height: 1.4;
  2410. }
  2411. .question-breakdown {
  2412. .breakdown-item {
  2413. margin-bottom: 6px;
  2414. .breakdown-row {
  2415. display: flex;
  2416. justify-content: space-between;
  2417. align-items: flex-start;
  2418. font-size: 13px;
  2419. .breakdown-left {
  2420. color: #4b5563;
  2421. flex: 1;
  2422. margin-right: 8px;
  2423. line-height: 1.3;
  2424. }
  2425. .breakdown-right {
  2426. color: #6b7280;
  2427. white-space: nowrap;
  2428. }
  2429. }
  2430. }
  2431. }
  2432. .divider {
  2433. height: 1px;
  2434. background: #e5e7eb;
  2435. margin: 8px 0;
  2436. }
  2437. .calculated-score-row,
  2438. .total-score-row {
  2439. display: flex;
  2440. justify-content: space-between;
  2441. margin-bottom: 4px;
  2442. font-size: 13px;
  2443. .calculated-label,
  2444. .total-label {
  2445. color: #6b7280;
  2446. }
  2447. .calculated-value,
  2448. .total-value {
  2449. color: #1f2937;
  2450. font-weight: 500;
  2451. }
  2452. }
  2453. }
  2454. }
  2455. }
  2456. .bottom-actions {
  2457. display: flex;
  2458. justify-content: center;
  2459. align-items: center;
  2460. gap: 24px;
  2461. margin-top: 20px;
  2462. width: 100%;
  2463. .clear-btn {
  2464. height: 34px;
  2465. padding: 0 16px;
  2466. border: 1px solid #d1d5db;
  2467. background: white;
  2468. color: #6b7280;
  2469. border-radius: 6px;
  2470. font-size: 14px;
  2471. font-weight: 500;
  2472. cursor: pointer;
  2473. transition: all 0.3s ease;
  2474. display: flex;
  2475. align-items: center;
  2476. justify-content: center;
  2477. &:disabled {
  2478. cursor: not-allowed !important;
  2479. pointer-events: none;
  2480. opacity: 0.5;
  2481. }
  2482. &:hover:not(:disabled) {
  2483. background: #f9fafb;
  2484. border-color: #9ca3af;
  2485. }
  2486. }
  2487. .generate-btn {
  2488. height: 34px;
  2489. padding: 0;
  2490. border: none;
  2491. background: none;
  2492. cursor: pointer;
  2493. transition: opacity 0.3s ease;
  2494. display: flex;
  2495. align-items: center;
  2496. justify-content: center;
  2497. &:disabled {
  2498. background: #f3f4f6;
  2499. color: #9ca3af;
  2500. cursor: not-allowed;
  2501. }
  2502. .generate-icon {
  2503. width: 107px;
  2504. height: 34px;
  2505. }
  2506. .generating-text {
  2507. width: 107px;
  2508. height: 34px;
  2509. display: flex;
  2510. align-items: center;
  2511. justify-content: center;
  2512. font-size: 16px;
  2513. font-weight: 600;
  2514. color: #374151;
  2515. background: #f3f4f6;
  2516. border-radius: 6px;
  2517. border: 1px solid #d1d5db;
  2518. }
  2519. &.disabled .generate-icon {
  2520. opacity: 0.5;
  2521. }
  2522. }
  2523. }
  2524. }
  2525. }
  2526. /* 考试详情页样式 */
  2527. .exam-detail-main {
  2528. .detail-header {
  2529. display: flex;
  2530. justify-content: space-between;
  2531. align-items: center;
  2532. padding: 12px 0;
  2533. margin-bottom: 16px;
  2534. .back-btn {
  2535. display: flex;
  2536. align-items: center;
  2537. gap: 6px;
  2538. padding: 8px 12px;
  2539. border-radius: 8px;
  2540. font-size: 13px;
  2541. cursor: pointer;
  2542. border: 1px solid #d1d5db;
  2543. background: white;
  2544. color: #374151;
  2545. transition: all 0.3s ease;
  2546. &:disabled {
  2547. opacity: 0.5;
  2548. cursor: not-allowed;
  2549. }
  2550. &:hover:not(:disabled) {
  2551. background: #f9fafb;
  2552. border-color: #9ca3af;
  2553. }
  2554. .back-arrow {
  2555. font-size: 14px;
  2556. }
  2557. }
  2558. .download-dropdown {
  2559. position: relative;
  2560. display: inline-block;
  2561. &.disabled {
  2562. opacity: 0.5;
  2563. cursor: not-allowed;
  2564. pointer-events: none;
  2565. }
  2566. .download-btn {
  2567. height: 34px;
  2568. padding: 0;
  2569. border: none;
  2570. background: transparent;
  2571. cursor: pointer;
  2572. transition: opacity 0.3s ease;
  2573. display: flex;
  2574. align-items: center;
  2575. justify-content: center;
  2576. &:disabled {
  2577. opacity: 0.5;
  2578. cursor: not-allowed !important;
  2579. pointer-events: none;
  2580. }
  2581. &:hover:not(:disabled) {
  2582. opacity: 0.8;
  2583. }
  2584. .download-icon {
  2585. width: 107px;
  2586. height: 34px;
  2587. }
  2588. }
  2589. .dropdown-menu {
  2590. position: absolute;
  2591. top: 100%;
  2592. right: 0;
  2593. background: white;
  2594. border: 1px solid #e5e7eb;
  2595. border-radius: 8px;
  2596. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  2597. z-index: 1000;
  2598. min-width: 120px;
  2599. opacity: 0;
  2600. visibility: hidden;
  2601. transform: translateY(-10px);
  2602. transition: all 0.3s ease;
  2603. .dropdown-item {
  2604. padding: 12px 16px;
  2605. cursor: pointer;
  2606. transition: background-color 0.2s ease;
  2607. border-bottom: 1px solid #f3f4f6;
  2608. &:last-child {
  2609. border-bottom: none;
  2610. }
  2611. &:hover {
  2612. background: #f8fafc;
  2613. }
  2614. &:disabled {
  2615. opacity: 0.5;
  2616. cursor: not-allowed;
  2617. pointer-events: none;
  2618. }
  2619. .item-text {
  2620. font-size: 14px;
  2621. color: #374151;
  2622. }
  2623. }
  2624. }
  2625. &.show .dropdown-menu {
  2626. opacity: 1;
  2627. visibility: visible;
  2628. transform: translateY(0);
  2629. }
  2630. }
  2631. }
  2632. .exam-info {
  2633. background: white;
  2634. border-radius: 12px;
  2635. padding: 20px;
  2636. margin-bottom: 16px;
  2637. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  2638. .exam-title {
  2639. font-size: 20px;
  2640. font-weight: 600;
  2641. color: #1f2937;
  2642. margin: 0 0 12px 0;
  2643. line-height: 1.4;
  2644. }
  2645. .exam-stats {
  2646. display: flex;
  2647. gap: 16px;
  2648. margin-bottom: 8px;
  2649. .total-score,
  2650. .question-count {
  2651. font-size: 15px;
  2652. color: #6b7280;
  2653. font-weight: 500;
  2654. }
  2655. }
  2656. .generation-time {
  2657. font-size: 14px;
  2658. color: #9ca3af;
  2659. }
  2660. }
  2661. .question-sections {
  2662. .question-section {
  2663. background: white;
  2664. border-radius: 12px;
  2665. margin-bottom: 16px;
  2666. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  2667. overflow: hidden;
  2668. .section-header {
  2669. display: flex;
  2670. justify-content: space-between;
  2671. align-items: center;
  2672. padding: 16px 20px;
  2673. background: #f8fafc;
  2674. border-bottom: 1px solid #e5e7eb;
  2675. .section-title {
  2676. flex: 1;
  2677. .section-number {
  2678. font-size: 16px;
  2679. font-weight: 600;
  2680. color: #1f2937;
  2681. }
  2682. .section-name {
  2683. font-size: 16px;
  2684. font-weight: 600;
  2685. color: #1f2937;
  2686. margin: 0 8px;
  2687. }
  2688. .section-score {
  2689. font-size: 14px;
  2690. color: #6b7280;
  2691. }
  2692. }
  2693. .section-controls {
  2694. display: flex;
  2695. align-items: center;
  2696. gap: 8px;
  2697. .question-count-text {
  2698. font-size: 14px;
  2699. color: #6b7280;
  2700. font-weight: 500;
  2701. }
  2702. .toggle-icon {
  2703. width: 16px;
  2704. height: 16px;
  2705. transition: transform 0.3s ease;
  2706. &.expanded {
  2707. transform: rotate(180deg);
  2708. }
  2709. }
  2710. }
  2711. }
  2712. .section-content {
  2713. padding: 0;
  2714. .question-item {
  2715. border-bottom: 1px solid #f3f4f6;
  2716. padding: 16px 20px;
  2717. &:last-child {
  2718. border-bottom: none;
  2719. }
  2720. .question-header {
  2721. display: flex;
  2722. align-items: flex-start;
  2723. gap: 8px;
  2724. margin-bottom: 12px;
  2725. .question-number {
  2726. font-size: 16px;
  2727. font-weight: 600;
  2728. color: #3e7bfa;
  2729. flex-shrink: 0;
  2730. }
  2731. .question-text {
  2732. flex: 1;
  2733. font-size: 16px;
  2734. line-height: 1.5;
  2735. color: #374151;
  2736. font-weight: 500;
  2737. }
  2738. .refresh-btn {
  2739. background: none;
  2740. border: none;
  2741. cursor: pointer;
  2742. padding: 4px;
  2743. color: #6b7280;
  2744. flex-shrink: 0;
  2745. &:disabled {
  2746. cursor: not-allowed;
  2747. opacity: 0.5;
  2748. }
  2749. &:hover:not(:disabled) {
  2750. color: #3e7bfa;
  2751. }
  2752. .refresh-icon {
  2753. width: 16px;
  2754. height: 16px;
  2755. transition: transform 0.3s ease;
  2756. &.rotating {
  2757. transform: rotate(360deg);
  2758. }
  2759. }
  2760. }
  2761. }
  2762. .options {
  2763. margin-bottom: 12px;
  2764. .option {
  2765. display: flex;
  2766. align-items: flex-start;
  2767. gap: 8px;
  2768. margin-bottom: 8px;
  2769. .radio-wrapper {
  2770. flex-shrink: 0;
  2771. margin-top: 2px;
  2772. .radio-circle {
  2773. width: 14px;
  2774. height: 14px;
  2775. border: 1.5px solid #d1d5db;
  2776. border-radius: 50%;
  2777. display: flex;
  2778. align-items: center;
  2779. justify-content: center;
  2780. transition: all 0.3s ease;
  2781. &.selected {
  2782. border-color: #3e7bfa;
  2783. background: #3e7bfa;
  2784. .radio-dot {
  2785. width: 6px;
  2786. height: 6px;
  2787. background: white;
  2788. border-radius: 50%;
  2789. }
  2790. }
  2791. }
  2792. }
  2793. .option-key {
  2794. font-size: 15px;
  2795. font-weight: 600;
  2796. color: #374151;
  2797. flex-shrink: 0;
  2798. }
  2799. .option-content {
  2800. flex: 1;
  2801. .option-text {
  2802. font-size: 15px;
  2803. line-height: 1.4;
  2804. color: #374151;
  2805. font-weight: 500;
  2806. }
  2807. }
  2808. }
  2809. }
  2810. .answer-section {
  2811. padding: 8px 12px;
  2812. background: #f8fafc;
  2813. border-radius: 6px;
  2814. display: flex;
  2815. align-items: center;
  2816. gap: 8px;
  2817. .answer-label {
  2818. font-size: 14px;
  2819. color: #6b7280;
  2820. font-weight: 600;
  2821. }
  2822. .answer-value {
  2823. font-size: 14px;
  2824. color: #1f2937;
  2825. font-weight: 600;
  2826. }
  2827. }
  2828. .answer-outline {
  2829. .outline-section {
  2830. margin-bottom: 12px;
  2831. padding: 12px;
  2832. background: #f8fafc;
  2833. border-radius: 6px;
  2834. font-size: 14px;
  2835. line-height: 1.5;
  2836. strong {
  2837. color: #374151;
  2838. display: block;
  2839. margin-bottom: 4px;
  2840. font-weight: 600;
  2841. }
  2842. color: #6b7280;
  2843. }
  2844. }
  2845. }
  2846. }
  2847. }
  2848. }
  2849. }
  2850. </style>