Bläddra i källkod

优化考试工坊

FanHong 3 veckor sedan
förälder
incheckning
0269ad884e

+ 266 - 117
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -72,7 +72,11 @@
                 <!-- 中间主操作区 -->
             <main class="main-content" style="padding-top: 36px; position: relative;">
                 <!-- 返回AI问答按钮 -->
-                <button v-if="!showExamDetail" class="return-ai-btn has-before" @click="handleReturnToAI">
+                <button v-if="!showExamDetail" class="return-ai-btn" @click="handleReturnToAI">
+                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <circle cx="12" cy="12" r="12" fill="white" stroke="#E5E7EB" stroke-width="1"/>
+                    <path d="M14 7L9 12L14 17" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+                  </svg>
                   返回AI问答
                 </button>
                 
@@ -86,7 +90,7 @@
                     <label class="form-label">出题依据内容</label>
                     <textarea class="form-control" v-model="questionBasis" placeholder="在此输入知识点、章节或培训内容..." :disabled="isGenerating || uploadedFiles.length > 0"></textarea>
                     
-                    <div class="ppt-upload-section" style="flex-direction: column; align-items: flex-start;" @click="!isGenerating ? triggerFileUpload() : null">
+                    <div class="ppt-upload-section" style="flex-direction: column; align-items: flex-start; justify-content: flex-start;" @click="!isGenerating ? triggerFileUpload() : null">
                         <div style="display: flex; width: 100%; justify-content: space-between; align-items: center;">
                             <div class="ppt-upload-content">
                                 <div class="ppt-upload-icon-wrapper">
@@ -127,7 +131,7 @@
                             </div>
                             <div class="slider-container">
                                 <span class="slider-label">数量</span>
-                                <input type="range" class="question-slider" v-model.number="type.questionCount" min="0" :max="type.max || 50" :disabled="isGenerating">
+                                <input type="range" class="question-slider" v-model.number="type.questionCount" :min="type.name === '单选题' ? 1 : 0" :max="type.max || 50" :disabled="isGenerating" @input="validateQuestionCount(type)">
                                 <div class="question-count-stepper">
                                     <span class="question-count">{{ type.questionCount }} 题</span>
                                     <div class="stepper-buttons">
@@ -142,7 +146,7 @@
                                             class="stepper-btn stepper-btn-down"
                                             type="button"
                                             @click="adjustQuestionCount(type, -1)"
-                                            :disabled="isGenerating || type.questionCount <= 0"
+                                            :disabled="isGenerating || (type.name === '单选题' ? type.questionCount <= 1 : type.questionCount <= 0)"
                                             aria-label="减少题目数量"
                                         ></button>
                                     </div>
@@ -195,7 +199,7 @@
                 </div>
 
                 <div class="preview-footer">
-                    <div class="preview-total">
+                    <div class="preview-total" style="font-size: 20px; color: #000000;">
                         <span>配置总分</span>
                         <span class="preview-total-score" style="color: #000000; font-size: 24px;">{{ totalScore }}</span>
                     </div>
@@ -216,15 +220,24 @@
             <div class="header-left">
             </div>
             <div class="header-right" style="display: flex; align-items: center; gap: 12px;">
-              <button class="return-ai-btn has-before" style="position: static;" @click="backToConfig" :disabled="isGenerating">
+              <button class="return-ai-btn" style="position: static;" @click="backToConfig" :disabled="isGenerating">
+                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <circle cx="12" cy="12" r="12" fill="white" stroke="#E5E7EB" stroke-width="1"/>
+                  <path d="M14 7L9 12L14 17" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
                 返回修改
               </button>
               <!-- <button class="save-btn" @click="saveExam" :disabled="isGenerating">
                 <img :src="saveIcon" alt="保存试卷" class="save-icon" />
               </button> -->
               <div class="download-dropdown" :class="{ 'disabled': isGenerating, 'show': showDownloadMenu }" @click.stop>
-                <button class="download-btn" :disabled="isGenerating" @click="toggleDownloadMenu">
-                  <img :src="downloadIcon" alt="下载Word" class="download-icon" />
+                <button class="download-btn-new" :disabled="isGenerating" @click="toggleDownloadMenu">
+                  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <rect x="1.5" y="1.5" width="21" height="21" rx="5" fill="white" stroke="#E5E7EB" stroke-width="1.5"/>
+                    <path d="M12 15L7.5 10H10.5V5H13.5V10H16.5L12 15Z" fill="currentColor"/>
+                    <path d="M6 17.5H18V19.5H6V17.5Z" fill="currentColor"/>
+                  </svg>
+                  <span class="download-icon-text">下载Word</span>
                 </button>
                 <div class="dropdown-menu">
                   <div class="dropdown-item" @click="exportToWordWithAnswers" :disabled="isGenerating">
@@ -317,6 +330,10 @@
                       </div>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -384,6 +401,10 @@
                       <span class="option-text">错误</span>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ question.selectedAnswer || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -452,6 +473,10 @@
                       </div>
                     </div>
                   </div>
+                  <div class="answer-section">
+                    <span class="answer-label">正确答案:</span>
+                    <span class="answer-value">{{ (question.selectedAnswers || []).join(', ') || '未设置' }}</span>
+                  </div>
                 </div>
               </div>
             </div>
@@ -820,7 +845,7 @@ const projectTypes = {
 
 // 题型配置
 const questionTypes = ref([
-  { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+  { name: "单选题", scorePerQuestion: 0, questionCount: 1, romanNumeral: "一" },
   { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
   { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
   { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
@@ -922,7 +947,7 @@ const createNewChat = async () => {
   } else {
     // 如果没有初始配置,使用默认配置
     questionTypes.value = [
-      { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+      { name: "单选题", scorePerQuestion: 0, questionCount: 1, romanNumeral: "一" },
       { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
       { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
       { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
@@ -1034,7 +1059,7 @@ const clearSettings = () => {
   // 保留原数组引用,更新每个对象的属性,避免破坏 Vue 3 响应式绑定
   questionTypes.value.forEach(type => {
     type.scorePerQuestion = 0;
-    type.questionCount = 0;
+    type.questionCount = type.name === '单选题' ? 1 : 0;
   });
   console.log("清除设置");
 };
@@ -1073,7 +1098,9 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     ElMessage.warning(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 0) {
+  if (type.name === '单选题' && type.questionCount < 1) {
+    type.questionCount = 1;
+  } else if (type.questionCount < 0) {
     type.questionCount = 0;
   }
 };
@@ -1122,6 +1149,14 @@ const generateExam = async () => {
     return;
   }
 
+  // 检查出题依据内容是否为空
+  const pptContents = uploadedFiles.value.map(file => file.content).join('\n\n');
+  const finalContentBasis = pptContents || questionBasis.value || '';
+  if (!finalContentBasis.trim()) {
+    ElMessage.warning("请输入出题依据内容或上传PPT文件");
+    return;
+  }
+
   console.log("生成试卷:", {
     function: selectedFunction.value,
     projectType: projectTypes[selectedProjectType.value].name,
@@ -1265,10 +1300,8 @@ const generateAIExam = async () => {
     
   } catch (error) {
     console.error('AI生成试卷失败:', error);
-    ElMessage.error('AI生成试卷失败,请稍后重试或检查网络连接');
-    
-    // 失败时显示默认试卷
-    showExamDetail.value = true;
+    ElMessage.error(error?.message || 'AI生成试卷失败,请稍后重试或检查网络连接');
+    showExamDetail.value = false;
   } finally {
     // 重置发送状态
     isGenerating.value = false;
@@ -1315,27 +1348,166 @@ const extractExamDataFromContent = (content) => {
     throw new Error('试卷内容为空');
   }
 
-  const jsonMatch = content.match(/\{[\s\S]*\}/);
-  if (!jsonMatch) {
-    throw new Error('未找到有效的JSON数据');
+  const cleaned = String(content)
+    .replace(/\uFEFF/g, '')
+    .replace(/```(?:json)?/gi, '')
+    .replace(/```/g, '')
+    .trim();
+
+  const extractJsonObjects = (text) => {
+    const objects = [];
+    let start = -1;
+    let depth = 0;
+    let inString = false;
+    let stringQuote = '"';
+    let escaped = false;
+
+    for (let i = 0; i < text.length; i++) {
+      const ch = text[i];
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+
+      if (inString) {
+        if (ch === '\\') {
+          escaped = true;
+          continue;
+        }
+        if (ch === stringQuote) {
+          inString = false;
+        }
+        continue;
+      }
+
+      if (ch === '"' || ch === "'") {
+        inString = true;
+        stringQuote = ch;
+        continue;
+      }
+
+      if (ch === '{') {
+        if (depth === 0) {
+          start = i;
+        }
+        depth += 1;
+        continue;
+      }
+
+      if (ch === '}' && depth > 0) {
+        depth -= 1;
+        if (depth === 0 && start >= 0) {
+          objects.push(text.slice(start, i + 1));
+          start = -1;
+        }
+      }
+    }
+
+    return objects;
+  };
+
+  const looksLikeExamPayload = (parsed) => {
+    if (!parsed || typeof parsed !== 'object') {
+      return false;
+    }
+    return Boolean(
+      parsed.singleChoice ||
+      parsed.single_choice ||
+      parsed['单选题'] ||
+      parsed.judge ||
+      parsed['判断题'] ||
+      parsed.multiple ||
+      parsed.multiple_choice ||
+      parsed.multipleChoice ||
+      parsed['多选题'] ||
+      parsed.short ||
+      parsed.short_answer ||
+      parsed.shortAnswer ||
+      parsed['简答题'] ||
+      parsed.questions?.single_choice ||
+      parsed.questions?.singleChoice ||
+      parsed.questions?.['单选题'] ||
+      parsed.questions?.judge ||
+      parsed.questions?.['判断题'] ||
+      parsed.questions?.multiple ||
+      parsed.questions?.multiple_choice ||
+      parsed.questions?.multipleChoice ||
+      parsed.questions?.['多选题'] ||
+      parsed.questions?.short ||
+      parsed.questions?.short_answer ||
+      parsed.questions?.shortAnswer ||
+      parsed.questions?.['简答题']
+    );
+  };
+
+  const candidates = extractJsonObjects(cleaned);
+  if (!candidates.length) {
+    throw new Error('未找到完整的JSON对象');
+  }
+
+  const parsedCandidates = [];
+  for (const candidate of candidates) {
+    try {
+      const normalizedJson = candidate
+        .replace(/[“”]/g, '"')
+        .replace(/,\s*([}\]])/g, '$1');
+      parsedCandidates.push(JSON.parse(normalizedJson));
+    } catch (_) {
+      // 跳过无效片段,继续寻找真正的试卷 JSON
+    }
+  }
+
+  const examCandidate = parsedCandidates.find(looksLikeExamPayload);
+  if (examCandidate) {
+    return examCandidate;
+  }
+
+  if (parsedCandidates.length > 0) {
+    return parsedCandidates.sort((a, b) => JSON.stringify(b).length - JSON.stringify(a).length)[0];
   }
 
-  return JSON.parse(jsonMatch[0]);
+  throw new Error('未找到可用的试卷JSON数据');
 };
 
 const parseAIExamResponse = (aiReply) => {
+  const hasPlaceholderContent = (value) => {
+    if (Array.isArray(value)) {
+      return value.some(item => hasPlaceholderContent(item));
+    }
+    if (value && typeof value === 'object') {
+      return Object.values(value).some(item => hasPlaceholderContent(item));
+    }
+    if (typeof value === 'string') {
+      const text = value.trim();
+      return [
+        /^题目内容$/,
+        /^解析内容$/,
+        /^参考措施$/,
+        /^答题要点$/,
+        /^具体选项内容$/,
+        /^选项[ABCD]$/,
+        /^.+工程相关(?:单选题|多选题|判断题|简答题)\d+$/,
+        /^.+相关(?:单选题|多选题|判断题|简答题)\d+$/
+      ].some(pattern => pattern.test(text));
+    }
+    return false;
+  };
+
   try {
     const examData = extractExamDataFromContent(aiReply);
     const normalizedExam = normalizeGeneratedExam(examData);
 
+    if (hasPlaceholderContent(normalizedExam)) {
+      throw new Error('AI返回的是占位题目,不是可用的具体试题');
+    }
+
     // 确保所有题目都有正确的初始值
     ensureQuestionInitialValues(normalizedExam);
 
     return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
-    // 返回默认试卷结构
-    return generateDefaultExam();
+    throw error;
   }
 };
 
@@ -1375,35 +1547,35 @@ const normalizeQuestions = (questions = [], sectionKey) => {
   return questions.map((question = {}) => {
     if (sectionKey === 'singleChoice') {
       return {
-        text: question.text || question.question_text || "",
+        text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
         options: normalizeOptions(question.options),
-        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
-        analysis: question.analysis || question.explanation || "",
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || question['正确答案'] || question['答案'] || "",
+        analysis: question.analysis || question.explanation || question['解析'] || "",
       };
     }
 
     if (sectionKey === 'judge') {
       return {
-        text: question.text || question.question_text || "",
-        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || "",
-        analysis: question.analysis || question.explanation || "",
+        text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
+        selectedAnswer: question.selectedAnswer || question.correct_answer || question.answer || question['正确答案'] || question['答案'] || "",
+        analysis: question.analysis || question.explanation || question['解析'] || "",
       };
     }
 
     if (sectionKey === 'multiple') {
-      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || [];
+      const selectedAnswers = question.selectedAnswers || question.correct_answers || question.answers || question.answer || question['正确答案'] || question['答案'] || [];
       return {
-        text: question.text || question.question_text || "",
+        text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
         options: normalizeOptions(question.options),
         selectedAnswers: Array.isArray(selectedAnswers) ? selectedAnswers : [selectedAnswers].filter(Boolean),
-        analysis: question.analysis || question.explanation || "",
+        analysis: question.analysis || question.explanation || question['解析'] || "",
       };
     }
 
     return {
-      text: question.text || question.question_text || "",
-      outline: question.outline || question.answer_outline || { keyFactors: question.answer || "答题要点、关键因素、示例答案" },
-      analysis: question.analysis || question.explanation || "",
+      text: question.text || question.question_text || question.question || question.title || question.content || question['题干'] || question['题目'] || "",
+      outline: question.outline || question.answer_outline || question['答题要点'] || { keyFactors: question.answer || question['答案'] || "答题要点、关键因素、示例答案" },
+      analysis: question.analysis || question.explanation || question['解析'] || "",
     };
   });
 };
@@ -1413,11 +1585,11 @@ const normalizeSection = (rawSection, sectionKey, fallbackName, fallbackScore =
   const config = getQuestionTypeConfig(fallbackName, fallbackScore);
   const sourceQuestions = Array.isArray(section)
     ? section
-    : (section.questions || section.items || section.question_list || []);
+    : (section.questions || section.items || section.question_list || section.questionList || section['题目'] || []);
   const normalizedQuestions = normalizeQuestions(sourceQuestions, sectionKey);
-  const count = Number(section.count ?? section.question_count ?? normalizedQuestions.length ?? config.questionCount) || 0;
-  const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? config.scorePerQuestion) || 0;
-  const totalScore = Number(section.totalScore ?? section.total_score ?? (scorePerQuestion * count)) || 0;
+  const count = Number(section.count ?? section.question_count ?? section['数量'] ?? normalizedQuestions.length ?? config.questionCount) || 0;
+  const scorePerQuestion = Number(section.scorePerQuestion ?? section.score_per_question ?? section['每题分值'] ?? config.scorePerQuestion) || 0;
+  const totalScore = Number(section.totalScore ?? section.total_score ?? section['总分'] ?? (scorePerQuestion * count)) || 0;
 
   return {
     scorePerQuestion,
@@ -1428,13 +1600,13 @@ const normalizeSection = (rawSection, sectionKey, fallbackName, fallbackScore =
 };
 
 const normalizeGeneratedExam = (examData = {}) => {
-  const singleSource = examData.singleChoice || examData.questions?.single_choice || examData.single_choice;
-  const judgeSource = examData.judge || examData.questions?.judge;
-  const multipleSource = examData.multiple || examData.questions?.multiple;
-  const shortSource = examData.short || examData.questions?.short;
+  const singleSource = examData.singleChoice || examData.questions?.single_choice || examData.questions?.singleChoice || examData.questions?.['单选题'] || examData.single_choice || examData['单选题'];
+  const judgeSource = examData.judge || examData.questions?.judge || examData.questions?.['判断题'] || examData['判断题'];
+  const multipleSource = examData.multiple || examData.multiple_choice || examData.multipleChoice || examData.questions?.multiple || examData.questions?.multiple_choice || examData.questions?.multipleChoice || examData.questions?.['多选题'] || examData['多选题'];
+  const shortSource = examData.short || examData.short_answer || examData.shortAnswer || examData.questions?.short || examData.questions?.short_answer || examData.questions?.shortAnswer || examData.questions?.['简答题'] || examData['简答题'];
 
   const normalizedExam = {
-    title: examData.title || examData.exam_name || examName.value,
+    title: examData.title || examData.exam_name || examData.examTitle || examData['试卷标题'] || examData['标题'] || examName.value,
     totalScore: Number(examData.totalScore ?? examData.total_score ?? totalScore.value) || 0,
     totalQuestions: Number(examData.totalQuestions ?? examData.total_questions) || 0,
     singleChoice: normalizeSection(singleSource, 'singleChoice', '单选题', 2),
@@ -3020,62 +3192,8 @@ const restoreExamFromHistory = (examData) => {
       ];
     }
     
-    // 恢复题目内容
-    if (exam.singleChoice || exam.questions?.single_choice) {
-      const singleChoice = exam.singleChoice || exam.questions.single_choice;
-      const judge = exam.judge || exam.questions.judge;
-      const multiple = exam.multiple || exam.questions.multiple;
-      const short = exam.short || exam.questions.short;
-      
-      console.log('单选题数据:', singleChoice);
-      console.log('判断题数据:', judge);
-      console.log('多选题数据:', multiple);
-      console.log('简答题数据:', short);
-      
-      currentExam.value = {
-        title: examName.value,
-        totalScore: totalScore.value,
-        totalQuestions: exam.totalQuestions || exam.total_questions,
-        singleChoice: {
-          scorePerQuestion: singleChoice.scorePerQuestion || singleChoice.score_per_question,
-          totalScore: singleChoice.totalScore || singleChoice.total_score,
-          count: singleChoice.count,
-          questions: singleChoice.questions.map(q => ({
-            text: q.text || q.question_text,
-            options: q.options || [],
-            selectedAnswer: q.selectedAnswer || ""
-          }))
-        },
-        judge: {
-          scorePerQuestion: judge.scorePerQuestion || judge.score_per_question,
-          totalScore: judge.totalScore || judge.total_score,
-          count: judge.count,
-          questions: judge.questions.map(q => ({
-            text: q.text || q.question_text,
-            selectedAnswer: q.selectedAnswer || q.correct_answer || q.answer || ""
-          }))
-        },
-        multiple: {
-          scorePerQuestion: multiple.scorePerQuestion || multiple.score_per_question,
-          totalScore: multiple.totalScore || multiple.total_score,
-          count: multiple.count,
-          questions: multiple.questions.map(q => ({
-            text: q.text || q.question_text,
-            options: q.options || [],
-            selectedAnswers: q.selectedAnswers || q.correct_answers || q.answers || []
-          }))
-        },
-        short: {
-          scorePerQuestion: short.scorePerQuestion || short.score_per_question,
-          totalScore: short.totalScore || short.total_score,
-          count: short.count,
-          questions: short.questions.map(q => ({
-            text: q.text || q.question_text,
-            outline: q.outline || q.answer_outline || { keyFactors: "答题要点、关键因素、示例答案" }
-          }))
-        }
-      };
-    }
+    currentExam.value = normalizeGeneratedExam(exam);
+    ensureQuestionInitialValues(currentExam.value);
     
     // 恢复用户答案(如果有)
     if (exam.user_answers) {
@@ -3790,7 +3908,7 @@ onUnmounted(() => {
 
     textarea.form-control {
         resize: none;
-        height: 250px;
+        height: 180px;
     }
 
     .char-count {
@@ -3814,6 +3932,9 @@ onUnmounted(() => {
         justify-content: space-between;
         box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 统一阴影 */
         position: relative;
+        height: 140px; /* 固定高度,防止上传文件后撑开高度导致下方模块下移 */
+        box-sizing: border-box;
+        overflow-y: auto; /* 文件过多时内部滚动 */
     }
 
     .ppt-upload-section:hover {
@@ -4956,20 +5077,32 @@ onUnmounted(() => {
           pointer-events: none;
         }
 
-        .download-btn {
-          // padding: 8px;
-          border: none;
-          background: transparent;
+        .download-btn-new {
+          border: 1px solid rgba(0, 0, 0, 0.06);
+          background: white;
+          border-radius: 12px;
+          box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+          padding: 6px 16px;
+          font-size: 13px;
+          font-weight: 500;
+          color: #28a745;
           cursor: pointer;
-          transition: opacity 0.3s ease;
+          display: flex;
+          align-items: center;
+          gap: 5px;
+          transition: all 0.3s ease;
+          height: 36px;
+          box-sizing: border-box;
 
-          &:hover {
-            opacity: 0.8;
+          &:hover:not(:disabled) {
+            box-shadow: 0 8px 24px rgba(40, 167, 69, 0.12);
+            color: #218838;
+            border-color: rgba(40, 167, 69, 0.2);
           }
-
-          .download-icon {
-            width: 107px;
-            height: 34px;
+          
+          &:disabled {
+            opacity: 0.5;
+            cursor: not-allowed;
           }
         }
 
@@ -5363,6 +5496,28 @@ onUnmounted(() => {
             }
           }
 
+          .answer-section {
+            margin-top: 12px;
+            padding: 10px 12px;
+            background: #f8fafc;
+            border-radius: 6px;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+
+            .answer-label {
+              font-size: 14px;
+              color: #6b7280;
+              font-weight: 600;
+            }
+
+            .answer-value {
+              font-size: 14px;
+              color: #1f2937;
+              font-weight: 600;
+            }
+          }
+
           .answer-box {
             .answer-outline {
               display: flex;
@@ -5793,10 +5948,4 @@ onUnmounted(() => {
   color: #0d6efd;
   border-color: rgba(13, 110, 253, 0.2);
 }
-
-.return-ai-btn.has-before::before {
-  content: '←';
-  font-size: 16px;
-  font-weight: bold;
-}
 </style>

+ 133 - 8
shudao-vue-frontend/src/views/mobile/m-ExamWorkshop.vue

@@ -140,7 +140,7 @@
                         v-model="type.questionCount"
                         type="number"
                         class="count-input-field"
-                        min="0"
+                        :min="type.name === '单选题' ? 1 : 0"
                         max="99"
                         @input="validateQuestionCount(type)"
                         :disabled="isGenerating || selectedFile"
@@ -157,7 +157,7 @@
                             class="stepper-btn stepper-btn-down"
                             type="button"
                             @click="adjustQuestionCount(type, -1)"
-                            :disabled="isGenerating || selectedFile || type.questionCount <= 0"
+                            :disabled="isGenerating || selectedFile || (type.name === '单选题' ? type.questionCount <= 1 : type.questionCount <= 0)"
                             aria-label="减少题目数量"
                           ></button>
                         </div>
@@ -933,7 +933,9 @@ const validateQuestionCount = (type) => {
     type.questionCount = 99;
     console.warn(`${type.name}题目数量不能超过99题`);
   }
-  if (type.questionCount < 0) {
+  if (type.name === '单选题' && type.questionCount < 1) {
+    type.questionCount = 1;
+  } else if (type.questionCount < 0) {
     type.questionCount = 0;
   }
 };
@@ -1056,6 +1058,8 @@ const generateExam = async () => {
 
   } catch (error) {
     console.error('生成试卷失败:', error);
+    showToast(error?.message || '生成试卷失败,请重试');
+    showExamDetail.value = false;
   } finally {
     isGenerating.value = false;
   }
@@ -1098,24 +1102,145 @@ const extractExamDataFromContent = (content) => {
     throw new Error('历史内容为空');
   }
 
-  const directMatch = content.match(/\{[\s\S]*\}/);
-  if (!directMatch) {
+  const cleaned = String(content)
+    .replace(/\uFEFF/g, '')
+    .replace(/```(?:json)?/gi, '')
+    .replace(/```/g, '')
+    .trim();
+
+  const extractJsonObjects = (text) => {
+    const objects = [];
+    let start = -1;
+    let depth = 0;
+    let inString = false;
+    let stringQuote = '"';
+    let escaped = false;
+
+    for (let i = 0; i < text.length; i++) {
+      const ch = text[i];
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+
+      if (inString) {
+        if (ch === '\\') {
+          escaped = true;
+          continue;
+        }
+        if (ch === stringQuote) {
+          inString = false;
+        }
+        continue;
+      }
+
+      if (ch === '"' || ch === "'") {
+        inString = true;
+        stringQuote = ch;
+        continue;
+      }
+
+      if (ch === '{') {
+        if (depth === 0) {
+          start = i;
+        }
+        depth += 1;
+        continue;
+      }
+
+      if (ch === '}' && depth > 0) {
+        depth -= 1;
+        if (depth === 0 && start >= 0) {
+          objects.push(text.slice(start, i + 1));
+          start = -1;
+        }
+      }
+    }
+
+    return objects;
+  };
+
+  const looksLikeExamPayload = (parsed) => {
+    if (!parsed || typeof parsed !== 'object') {
+      return false;
+    }
+    return Boolean(
+      parsed.singleChoice ||
+      parsed.single_choice ||
+      parsed.judge ||
+      parsed.multiple ||
+      parsed.short ||
+      parsed.questions?.single_choice ||
+      parsed.questions?.judge ||
+      parsed.questions?.multiple ||
+      parsed.questions?.short
+    );
+  };
+
+  const candidates = extractJsonObjects(cleaned);
+  if (!candidates.length) {
     throw new Error('未找到可解析的试卷JSON');
   }
 
-  return JSON.parse(directMatch[0]);
+  const parsedCandidates = [];
+  for (const candidate of candidates) {
+    try {
+      const normalizedJson = candidate
+        .replace(/[“”]/g, '"')
+        .replace(/,\s*([}\]])/g, '$1');
+      parsedCandidates.push(JSON.parse(normalizedJson));
+    } catch (_) {
+      // 继续寻找真正的试卷 JSON
+    }
+  }
+
+  const examCandidate = parsedCandidates.find(looksLikeExamPayload);
+  if (examCandidate) {
+    return examCandidate;
+  }
+
+  if (parsedCandidates.length > 0) {
+    return parsedCandidates.sort((a, b) => JSON.stringify(b).length - JSON.stringify(a).length)[0];
+  }
+
+  throw new Error('未找到可用的试卷JSON数据');
 };
 
 const parseAIExamResponse = (aiReply) => {
+  const hasPlaceholderContent = (value) => {
+    if (Array.isArray(value)) {
+      return value.some(item => hasPlaceholderContent(item));
+    }
+    if (value && typeof value === 'object') {
+      return Object.values(value).some(item => hasPlaceholderContent(item));
+    }
+    if (typeof value === 'string') {
+      const text = value.trim();
+      return [
+        /^题目内容$/,
+        /^解析内容$/,
+        /^参考措施$/,
+        /^答题要点$/,
+        /^具体选项内容$/,
+        /^选项[ABCD]$/,
+        /^.+工程相关(?:单选题|多选题|判断题|简答题)\d+$/,
+        /^.+相关(?:单选题|多选题|判断题|简答题)\d+$/
+      ].some(pattern => pattern.test(text));
+    }
+    return false;
+  };
+
   try {
     const examData = extractExamDataFromContent(aiReply);
     const normalizedExam = normalizeGeneratedExam(examData);
+    if (hasPlaceholderContent(normalizedExam)) {
+      throw new Error('AI返回的是占位题目,不是可用的具体试题');
+    }
     ensureQuestionInitialValues(normalizedExam);
     return normalizedExam;
   } catch (error) {
     console.error('解析AI回复失败:', error);
-    // 返回默认试卷结构
-    return generateDefaultExam();
+    throw error;
   }
 };