diff --git a/backend/agents/note/agent.py b/backend/agents/note/agent.py index 0a26c99a..17194685 100644 --- a/backend/agents/note/agent.py +++ b/backend/agents/note/agent.py @@ -4,9 +4,12 @@ 支持: - 重试机制(指数退避,最多 3 次) - 多页分批处理(超过阈值时分批整理再合并) + - 多模式结构化输出(function_calling → prompt JSON fallback) """ +import json import logging +import re import time from langchain_core.messages import SystemMessage, HumanMessage @@ -22,6 +25,32 @@ BATCH_CHAR_LIMIT = 6000 MAX_RETRIES = 3 +_messages = lambda ocr_text: [ + SystemMessage(content=NOTE_ORGANIZE_PROMPT), + HumanMessage( + content=f"以下是 OCR 识别出的课堂笔记原始文本,请整理为结构化笔记:\n\n{ocr_text}" + ), +] + + +def _extract_json_from_text(text: str) -> dict | None: + """从 LLM 输出文本中提取 JSON 对象(兼容 ```json ... ``` 包裹)""" + # 尝试 ```json ... ``` 代码块 + m = re.search(r"```json\s*\n?(.*?)\n?\s*```", text, re.DOTALL) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + pass + # 尝试裸 JSON 对象 + m = re.search(r"\{[\s\S]*\}", text) + if m: + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + pass + return None + def _invoke_once( model, @@ -29,42 +58,46 @@ def _invoke_once( provider: str = "openai", supports_function_calling: bool = True, ) -> OrganizedNote: - """单次 LLM 调用,返回结构化笔记""" - from core.config import settings - - # 百度千帆等特殊平台/模型,即使 supports_function_calling=False - # 如果直接用 with_structured_output 仍可能报错 "暂不支持该模型"(如果 Langchain 默认使用了 JSON Schema mode) - # 为保证最大兼容性,我们通过 prompt 要求输出 JSON,并手动解析 - if not supports_function_calling: - from langchain_core.output_parsers import PydanticOutputParser - - parser = PydanticOutputParser(pydantic_object=OrganizedNote) - format_instructions = parser.get_format_instructions() - - response = model.invoke( - [ - SystemMessage( - content=NOTE_ORGANIZE_PROMPT - + f"\n\n你必须以 JSON 格式输出,且遵循以下结构:\n{format_instructions}" - ), - HumanMessage( - content=f"以下是 OCR 识别出的课堂笔记原始文本,请整理为结构化笔记:\n\n{ocr_text}" - ), - ] - ) + """单次 LLM 调用,返回结构化笔记。按优先级尝试多种输出模式。""" + messages = _messages(ocr_text) + + # 模式 1: with_structured_output(function_calling / json_schema,取决于模型) + if supports_function_calling: + try: + structured_model = model.with_structured_output(OrganizedNote) + return structured_model.invoke(messages) + except Exception as exc: + logger.warning( + "笔记整理: with_structured_output 失败 (%s),fallback 到 prompt JSON", exc + ) + + # 模式 2: prompt JSON — 在 system prompt 中要求输出 JSON,并手动解析 + from langchain_core.output_parsers import PydanticOutputParser + + parser = PydanticOutputParser(pydantic_object=OrganizedNote) + format_instructions = parser.get_format_instructions() + + prompt_messages = [ + SystemMessage( + content=NOTE_ORGANIZE_PROMPT + + f"\n\n你必须以 JSON 格式输出,且遵循以下结构:\n{format_instructions}" + ), + messages[1], # HumanMessage + ] + response = model.invoke(prompt_messages) + + # 先尝试 PydanticOutputParser(依赖严格格式) + try: return parser.parse(response.content) - else: - structured_model = model.with_structured_output( - OrganizedNote, method="function_calling" - ) - return structured_model.invoke( - [ - SystemMessage(content=NOTE_ORGANIZE_PROMPT), - HumanMessage( - content=f"以下是 OCR 识别出的课堂笔记原始文本,请整理为结构化笔记:\n\n{ocr_text}" - ), - ] - ) + except Exception: + pass + + # 再尝试从文本中提取 JSON 并手动构建 + data = _extract_json_from_text(response.content) + if data: + return OrganizedNote.model_validate(data) + + raise RuntimeError("LLM 未返回合法的 JSON 结构") def _invoke_with_retry( diff --git a/backend/core/config.py b/backend/core/config.py index 9876d50b..0fcc28e6 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -316,10 +316,6 @@ def build_provider_config( ) -> LLMProviderConfig: key = self._normalize_provider(name) - # 处理可能包含多个模型的字符串(取第一个作为默认) - if model_name and "," in model_name: - model_name = [m.strip() for m in model_name.split(",") if m.strip()][0] - if key == "openai": return OpenAICompatibleConfig( api_key=api_key, @@ -508,7 +504,7 @@ def load_providers_from_db( if owns_db: db = SessionLocal() try: - for category in [("openai"), ("anthropic")]: + for category in ("openai", "anthropic"): provider = get_active_provider(db, user_id, category) if provider and provider.api_key: cfg = self.build_provider_config( diff --git a/backend/core/model_selection.py b/backend/core/model_selection.py index 4a218e56..a2d09cfd 100644 --- a/backend/core/model_selection.py +++ b/backend/core/model_selection.py @@ -22,7 +22,9 @@ class LLMSelectionError(Exception): def split_models(model_name: str | None) -> list[str]: - return [item.strip() for item in (model_name or "").split(",") if item.strip()] + """返回模型名称列表。现在只支持单个模型,但保留列表形式以兼容调用方。""" + name = (model_name or "").strip() + return [name] if name else [] def build_managed_provider_context(db): diff --git a/backend/db/crud/projects.py b/backend/db/crud/projects.py index b49f126c..993584ab 100644 --- a/backend/db/crud/projects.py +++ b/backend/db/crud/projects.py @@ -4,7 +4,10 @@ from sqlalchemy.orm import Session -from db.models import Note, Project, Question, UploadBatch +from db.models import ( + ChatSession, Note, NoteTagMapping, Project, Question, + QuestionEmbedding, QuestionTagMapping, UploadBatch, +) VALID_PROJECT_TYPES = {"question", "note"} @@ -135,14 +138,18 @@ def delete_project(db: Session, project_id: int, user_id=None) -> bool: if project.is_default: raise ValueError("DEFAULT_PROJECT_IMMUTABLE") - # 检查是否有题目或笔记 - has_questions = db.query(Question.id).filter(Question.project_id == project.id).first() - has_notes = db.query(Note.id).filter(Note.project_id == project.id).first() - - if has_questions or has_notes: - raise ValueError("PROJECT_NOT_EMPTY") - - # 如果没有题目和笔记了,自动清理关联的空批次(UploadBatch) + # 先删除题目和笔记的关联子表,再删除题目/笔记本身 + question_ids = [q.id for q in db.query(Question.id).filter(Question.project_id == project.id).all()] + if question_ids: + db.query(QuestionEmbedding).filter(QuestionEmbedding.question_id.in_(question_ids)).delete(synchronize_session=False) + db.query(ChatSession).filter(ChatSession.question_id.in_(question_ids)).delete(synchronize_session=False) + db.query(QuestionTagMapping).filter(QuestionTagMapping.question_id.in_(question_ids)).delete(synchronize_session=False) + db.query(Question).filter(Question.id.in_(question_ids)).delete(synchronize_session=False) + + note_ids = [n.id for n in db.query(Note.id).filter(Note.project_id == project.id).all()] + if note_ids: + db.query(NoteTagMapping).filter(NoteTagMapping.note_id.in_(note_ids)).delete(synchronize_session=False) + db.query(Note).filter(Note.id.in_(note_ids)).delete(synchronize_session=False) db.query(UploadBatch).filter(UploadBatch.project_id == project.id).delete() db.delete(project) diff --git a/backend/routes/projects.py b/backend/routes/projects.py index 90235f61..e0768338 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -113,8 +113,6 @@ def delete_project(project_id): except ValueError as exc: if str(exc) == "DEFAULT_PROJECT_IMMUTABLE": return jsonify({"success": False, "error": "默认项目不能删除"}), 400 - if str(exc) == "PROJECT_NOT_EMPTY": - return jsonify({"success": False, "error": "项目里还有内容,暂时不能删除"}), 400 raise if not deleted: return jsonify({"success": False, "error": "项目不存在"}), 404 diff --git a/backend/routes/questions.py b/backend/routes/questions.py index ee4ad4fa..267bdc58 100644 --- a/backend/routes/questions.py +++ b/backend/routes/questions.py @@ -504,7 +504,10 @@ def save_to_db(): Request Body: { - "selected_ids": ["q_0", "q_1", ...] # 选中的题目 ID 列表 + "selected_ids": ["q_0", "q_1", ...], # 选中的题目 ID 列表 + "run_id": "xxx", # 可选,从 WorkflowRun 读取题目 + "record_id": 123, # 可选,从 SplitRecord 读取题目(历史记录导入) + "project_id": 456 # 目标错题库 ID } """ try: @@ -516,14 +519,39 @@ def save_to_db(): return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 run_id = data.get('run_id') - if not run_id: - return jsonify({'success': False, 'code': 'MISSING_RUN_ID', 'error': '缺少 run_id,请重新分割题目'}), 400 + record_id = data.get('record_id') user_id = session.get('user_id') - with SessionLocal() as db: - run = run_store.get_split_run(db, run_id, user_id=user_id) - if not run or run.status != run_store.STATUS_SUCCEEDED: - return jsonify({'success': False, 'error': '请先分割题目'}), 400 - questions = run_store.read_questions(run) + + # 优先从 SplitRecord 读取(历史记录导入场景) + if record_id: + with SessionLocal() as db: + record = crud.get_split_record_by_id(db, record_id, user_id=user_id) + if not record: + return jsonify({'success': False, 'error': '分割记录不存在'}), 404 + questions = json.loads(record.questions_json) if record.questions_json else [] + subject = record.subject or '' + file_names = json.loads(record.file_names_json) if record.file_names_json else [] + batch_info = { + "original_filename": ", ".join(file_names) or "Unknown", + "subject": subject, + "file_path": "", + } + # 从 WorkflowRun 读取(正常分割后导入场景) + elif run_id: + with SessionLocal() as db: + run = run_store.get_split_run(db, run_id, user_id=user_id) + if not run or run.status != run_store.STATUS_SUCCEEDED: + return jsonify({'success': False, 'error': '请先分割题目'}), 400 + questions = run_store.read_questions(run) + subject = run_store.read_subject(run) + file_names = json.loads(run.file_names_json) if run.file_names_json else [] + batch_info = { + "original_filename": ", ".join(file_names) or "Unknown", + "subject": subject, + "file_path": run.result_dir, + } + else: + return jsonify({'success': False, 'code': 'MISSING_SOURCE', 'error': '缺少 run_id 或 record_id'}), 400 uid_set = set(selected_uids) selected_questions = [q for q in questions if q.get('uid') in uid_set] @@ -540,16 +568,6 @@ def save_to_db(): if 'user_answer' in answers_map[uid]: sq['user_answer'] = answers_map[uid]['user_answer'] - # 读取科目信息 - subject = run_store.read_subject(run) - - file_names = json.loads(run.file_names_json) if run.file_names_json else [] - batch_info = { - "original_filename": ", ".join(file_names) or "Unknown", - "subject": subject, - "file_path": run.result_dir, - } - with SessionLocal() as db: try: project_id = ( @@ -572,7 +590,7 @@ def save_to_db(): return jsonify({ 'success': True, 'message': f'已导入 {result["created"]} 道题目(跳过 {result["duplicates"]} 道重复)', - 'run_id': run.public_id, + 'run_id': run_id, 'created': result['created'], 'duplicates': result['duplicates'], }) diff --git a/backend/routes/settings.py b/backend/routes/settings.py index b37dff0d..c6e4ebc6 100644 --- a/backend/routes/settings.py +++ b/backend/routes/settings.py @@ -109,11 +109,7 @@ def get_status(): crud.get_active_provider(db, user_id, category) if user_id else None ) if provider and provider.api_key: - models = ( - [m.strip() for m in provider.model_name.split(",")] - if provider.model_name - else [] - ) + models = [provider.model_name] if provider.model_name else [] available_models.append( { "value": category, @@ -128,7 +124,7 @@ def get_status(): else: managed_cfg = managed_llm.get(category) managed_models = ( - [m.strip() for m in managed_cfg.model_name.split(",")] + [managed_cfg.model_name] if managed_cfg and managed_cfg.configured and managed_cfg.model_name @@ -302,7 +298,7 @@ def list_models(): else: provider = crud.get_active_provider(db, user_id, provider_type) if provider: - api_key = api_key or provider.api_key or "" + api_key = provider.api_key or "" base_url = base_url or provider.base_url or "" if not api_key and provider_type in ("openai", "anthropic"): system_provider = crud.get_active_system_provider(db, provider_type) @@ -413,7 +409,7 @@ def test_paddleocr(): else: provider = crud.get_active_provider(db, user_id, "paddleocr") if provider: - api_token = api_token or provider.api_key or "" + api_token = provider.api_key or "" api_url = api_url or provider.base_url or "" if not api_token or not api_url: system_provider = crud.get_active_system_provider(db, "paddleocr") diff --git a/frontend/src/api/upload.js b/frontend/src/api/upload.js index 4e2bcedc..580b8331 100644 --- a/frontend/src/api/upload.js +++ b/frontend/src/api/upload.js @@ -83,9 +83,10 @@ export async function exportQuestions(selectedIds, runId) { } /** 将选中的分割题目和答案保存到错题库。 */ -export async function saveToDb(selectedIds, answers = [], runId, projectId) { +export async function saveToDb(selectedIds, answers = [], runId, projectId, recordId) { const body = { selected_ids: selectedIds, answers } if (runId) body.run_id = runId + if (recordId) body.record_id = recordId if (projectId) body.project_id = projectId const resp = await fetch('/api/save-to-db', { method: 'POST', diff --git a/frontend/src/components/base/BaseModal.vue b/frontend/src/components/base/BaseModal.vue index f43ac237..c3ca8222 100644 --- a/frontend/src/components/base/BaseModal.vue +++ b/frontend/src/components/base/BaseModal.vue @@ -11,6 +11,7 @@ const props = defineProps({ bodyClass: { type: String, default: 'px-6 py-5' }, blurBackdrop: { type: Boolean, default: true }, sidebarOffset: { type: Number, default: null }, + zIndex: { type: Number, default: null }, }) const emit = defineEmits(['close']) @@ -28,7 +29,7 @@ const backdropStyle = computed(() => ({
@@ -37,6 +38,7 @@ const backdropStyle = computed(() => ({
+/** + * ErrorQuestionRecommendAside.vue + * 错题库右侧同类题推荐栏。 + * 基于知识点重叠、学科、题型和内容相似度,纯前端筛选推荐相似题目。 + */ +import { computed } from 'vue' +import BaseEmptyState from '@/components/base/BaseEmptyState.vue' +import BasePanel from '@/components/base/BasePanel.vue' +import BaseTag from '@/components/base/BaseTag.vue' +import { getQuestionSnippet } from '@/utils/index.js' + +const props = defineProps({ + currentQuestion: { type: Object, default: null }, + allItems: { type: Array, default: () => [] }, +}) + +const emit = defineEmits(['select-question']) + +/** + * 提取题目文本内容的分词 + */ +const getTokens = (question) => { + const blocks = question.content_json || [] + return blocks + .filter(b => b.block_type === 'text') + .map(b => b.content || '') + .join(' ') + .replace(/<[^>]+>/g, '') + .split(/[\s,,。、;:!?""''()\(\)]+/) + .filter(t => t.length >= 2) +} + +/** + * 生成推荐理由 + */ +const generateReasons = (matchedTags, current, candidate) => { + const reasons = [] + if (matchedTags.length > 0) { + reasons.push(`共同知识点:${matchedTags.slice(0, 2).join('、')}`) + } + if (current.subject === candidate.subject && current.subject) { + reasons.push(`同属${current.subject}学科`) + } + if (current.question_type === candidate.question_type && current.question_type) { + reasons.push(`同为${current.question_type}`) + } + return reasons.slice(0, 3) +} + +/** + * 计算两道题目的相似度(0-100) + */ +const computeSimilarity = (current, candidate) => { + if (!current || !candidate || current.id === candidate.id) return null + + // 1. 知识点重叠(Jaccard相似度,权重0.5) + const currentTags = new Set(current.knowledge_tags || []) + const candidateTags = new Set(candidate.knowledge_tags || []) + const intersection = new Set([...currentTags].filter(t => candidateTags.has(t))) + const union = new Set([...currentTags, ...candidateTags]) + const tagSimilarity = union.size > 0 ? intersection.size / union.size : 0 + + // 2. 学科匹配(权重0.2) + const subjectSimilarity = current.subject === candidate.subject ? 1 : 0 + + // 3. 题型匹配(权重0.15) + const typeSimilarity = current.question_type === candidate.question_type ? 1 : 0 + + // 4. 内容关键词重叠(权重0.15) + const currentTokens = new Set(getTokens(current)) + const candidateTokens = new Set(getTokens(candidate)) + const tokenIntersection = new Set([...currentTokens].filter(t => candidateTokens.has(t))) + const tokenUnion = new Set([...currentTokens, ...candidateTokens]) + const contentSimilarity = tokenUnion.size > 0 ? tokenIntersection.size / tokenUnion.size : 0 + + // 加权计算总分 + const score = tagSimilarity * 0.5 + subjectSimilarity * 0.2 + typeSimilarity * 0.15 + contentSimilarity * 0.15 + + return { + question: candidate, + similarity: Math.round(score * 100), + matchedTags: [...intersection], + matchReasons: generateReasons([...intersection], current, candidate), + } +} + +/** + * 推荐题目列表(按相似度降序,最多5道) + */ +const recommended = computed(() => { + if (!props.currentQuestion || !props.allItems.length) return [] + return props.allItems + .map(item => computeSimilarity(props.currentQuestion, item)) + .filter(Boolean) + .filter(r => r.similarity > 0) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, 5) +}) + +/** + * 相似度徽章颜色 + */ +const similarityBadgeClass = (similarity) => { + if (similarity >= 80) return 'bg-emerald-500/15 text-emerald-300' + if (similarity >= 60) return 'bg-amber-500/15 text-amber-300' + return 'bg-gray-500/15 text-gray-400' +} + + + diff --git a/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue b/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue index e405949f..c655a5ba 100644 --- a/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue +++ b/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue @@ -28,22 +28,128 @@ let resizeObserver = null const cloudColors = ['#34d399', '#60a5fa', '#facc15', '#a78bfa', '#fb7185', '#22d3ee', '#fb923c', '#cbd5e1'] -const mockMistakeAnalysis = { - knowledge_points: ['圆锥体积', '立体几何', '统计', '样本方差', '假设检验'], - error_types: ['概念混淆', '公式误用', '审题遗漏'], - error_symptoms: ['把题目中的处理效应与样本差异混在一起判断。'], - correction_suggestions: [ - '先复习统计量、样本方差和假设检验的适用条件。', - '补做 3-5 道同类统计推断题,重点训练变量含义识别。', - '解题时先圈出实验对象、处理方式和待比较指标。', - ], - confidence: 0.82, +/** + * 错因类型模板库 + * 每种错因类型包含症状和修正建议的模板,支持 {tag}/{tag1}/{tag2} 占位符 + */ +const ERROR_TYPE_PROFILES = [ + { + type: '概念混淆', + symptoms: [ + '将 {tag1} 与 {tag2} 的定义条件混淆,未区分两者的适用范围。', + '对 {tag} 的核心概念理解偏差,套用了错误的解题框架。', + ], + suggestions: [ + '对比整理 {tag} 与易混淆概念的定义差异,制作对照表。', + '重做 2-3 道以 {tag} 为核心的选择题,重点识别概念边界。', + ], + confidenceRange: [0.72, 0.88], + }, + { + type: '公式误用', + symptoms: [ + '{tag} 相关公式记混或用错,代入了不匹配的变量。', + '在 {tag} 的计算中,未注意公式的前提条件。', + ], + suggestions: [ + '整理 {tag} 涉及的核心公式,标注每个公式的适用条件。', + '做 3 道变形题,训练识别题目条件与公式的对应关系。', + ], + confidenceRange: [0.68, 0.85], + }, + { + type: '审题遗漏', + symptoms: [ + '忽略了题干中关于 {tag} 的关键限定条件。', + '跳读导致遗漏了题目中的隐含条件或单位换算。', + ], + suggestions: [ + '审题时逐句圈画关键词,尤其是限定词、单位和否定词。', + '训练"读题-画关键信息-列条件"的三步审题习惯。', + ], + confidenceRange: [0.75, 0.92], + }, + { + type: '计算错误', + symptoms: [ + '{tag} 的推导过程正确,但在中间步骤出现计算失误。', + '运算过程中符号处理不当,导致最终结果偏离。', + ], + suggestions: [ + '分步书写计算过程,每步结果单独验算。', + '整理易错运算类型(如正负号、括号展开),建立个人检查清单。', + ], + confidenceRange: [0.82, 0.95], + }, + { + type: '知识点遗忘', + symptoms: [ + '对 {tag} 的基础定理/公式记忆模糊,解题时无法快速调用。', + '缺乏对 {tag} 相关知识体系的整体回顾。', + ], + suggestions: [ + '针对 {tag} 做一次完整知识点回顾,重新推导核心公式。', + '制作 {tag} 的思维导图,建立知识脉络。', + ], + confidenceRange: [0.65, 0.80], + }, + { + type: '解题思路错误', + symptoms: [ + '面对 {tag} 相关问题时,选择了低效或错误的切入点。', + '缺乏从题目条件到 {tag} 解法的逻辑推演能力。', + ], + suggestions: [ + '总结 {tag} 常见题型的解题模板,归纳通用思路。', + '对比正确解法和自己的思路,分析偏差产生的环节。', + ], + confidenceRange: [0.60, 0.78], + }, +] + +/** + * 根据知识点标签确定性生成错因分析 + * 使用标签字符串长度作为哈希种子,确保同一题目每次生成相同的分析 + */ +const generateMockAnalysis = (tags) => { + const safeTags = tags.length ? tags : ['基础知识'] + const tagHash = safeTags.join('').length + + // 根据哈希值确定错因类型数量(2-3种) + const typeCount = 2 + (tagHash % 2) + + // 确定性洗牌选择错因类型 + const shuffled = [...ERROR_TYPE_PROFILES].sort((a, b) => { + return (a.type.length + tagHash) % 3 - (b.type.length + tagHash) % 3 + }) + const selectedTypes = shuffled.slice(0, typeCount) + + // 填充模板占位符 + const fillTemplate = (template) => { + return template + .replace('{tag}', safeTags[0]) + .replace('{tag1}', safeTags[0]) + .replace('{tag2}', safeTags[1] || safeTags[0]) + } + + const primary = selectedTypes[0] + const confidence = primary.confidenceRange[0] + + (tagHash % 100) / 100 * (primary.confidenceRange[1] - primary.confidenceRange[0]) + + return { + knowledge_points: safeTags, + error_types: selectedTypes.map(t => t.type), + error_symptoms: selectedTypes.map(t => + fillTemplate(t.symptoms[tagHash % t.symptoms.length]) + ), + correction_suggestions: selectedTypes.slice(0, 2).map(t => + fillTemplate(t.suggestions[0]) + ), + confidence: Math.round(confidence * 100) / 100, + } } -const mistakeAnalysis = computed(() => ({ - ...mockMistakeAnalysis, - knowledge_points: props.knowledgeTags.length ? props.knowledgeTags : mockMistakeAnalysis.knowledge_points, -})) +const mistakeAnalysis = computed(() => generateMockAnalysis(props.knowledgeTags)) const wordCloudData = computed(() => mistakeAnalysis.value.knowledge_points.slice(0, 12).map((tag, index) => ({ name: tag, @@ -155,8 +261,15 @@ onBeforeUnmount(() => {

错误表现

-
- {{ mistakeAnalysis.error_symptoms[0] }} +
+
+ {{ idx + 1 }}. + {{ symptom }} +
diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue b/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue index 79390700..23c8ffca 100644 --- a/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue +++ b/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue @@ -32,6 +32,7 @@ const emit = defineEmits([ 'open-chat', 'start-practice', 'back-to-list', + 'open-recommend', ]) const statusTone = (status) => { @@ -96,7 +97,7 @@ const setActiveTab = (value) => emit('update:activeTab', value) class="rounded-xl bg-gray-50/80 px-4 py-3 text-sm font-medium text-gray-700 dark:bg-white/[0.035] dark:text-[#d0d6e0]" > {{ String.fromCharCode(65 + index) }}. - {{ formatOption(option).replace(/^[A-Da-d][.、.]\s*/, '') }} +
@@ -152,7 +153,7 @@ const setActiveTab = (value) => emit('update:activeTab', value) 加入复习 - + 同类题 diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionRecommendAside.vue b/frontend/src/components/features/app/error-bank/ErrorQuestionRecommendAside.vue new file mode 100644 index 00000000..0a435d77 --- /dev/null +++ b/frontend/src/components/features/app/error-bank/ErrorQuestionRecommendAside.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/components/features/app/settings/ProviderDialog.vue b/frontend/src/components/features/app/settings/ProviderDialog.vue index 172f7fcd..b4427553 100644 --- a/frontend/src/components/features/app/settings/ProviderDialog.vue +++ b/frontend/src/components/features/app/settings/ProviderDialog.vue @@ -21,13 +21,12 @@ const emit = defineEmits(['close', 'confirm']) const isEdit = computed(() => !!props.editData) -const typeConfig = computed(() => ({ +const PROVIDER_TYPE_CONFIGS = { openai: { - title: isEdit.value ? '编辑 OpenAI 兼容供应商' : '添加 OpenAI 兼容供应商', - iconBg: 'bg-gray-50 dark:bg-white/[0.04] border border-gray-100 dark:border-white/[0.08]', + titleAdd: '添加 OpenAI 兼容供应商', + titleEdit: '编辑 OpenAI 兼容供应商', iconCls: 'fa-bolt text-slate-600 dark:text-slate-400', imgIcon: '/src/assets/provider-openai.svg', - btnCls: 'bg-slate-900 hover:bg-slate-800 text-white dark:bg-[#f7f8f8] dark:hover:bg-white dark:text-[#1b1b1d]', namePlaceholder: '例如:DeepSeek / Qwen / Moonshot', urlPlaceholder: '留空使用 OpenAI 官方,或填入 https://api.deepseek.com 等', modelPlaceholder: 'gpt-4o-mini', @@ -37,11 +36,10 @@ const typeConfig = computed(() => ({ urlLabel: 'Base URL', }, anthropic: { - title: isEdit.value ? '编辑 Anthropic 供应商' : '添加 Anthropic 供应商', - iconBg: 'bg-gray-50 dark:bg-white/[0.04] border border-gray-100 dark:border-white/[0.08]', + titleAdd: '添加 Anthropic 供应商', + titleEdit: '编辑 Anthropic 供应商', iconCls: 'fa-brain text-slate-600 dark:text-slate-400', imgIcon: '/src/assets/provider-anthropic.svg', - btnCls: 'bg-slate-900 hover:bg-slate-800 text-white dark:bg-[#f7f8f8] dark:hover:bg-white dark:text-[#1b1b1d]', namePlaceholder: '例如:Claude Official', urlPlaceholder: '留空使用 Anthropic 官方', modelPlaceholder: 'claude-sonnet-4-20250514', @@ -51,11 +49,10 @@ const typeConfig = computed(() => ({ urlLabel: 'Base URL', }, paddleocr: { - title: isEdit.value ? '编辑 PaddleOCR 服务' : '添加 PaddleOCR 服务', - iconBg: 'bg-gray-50 dark:bg-white/[0.04] border border-gray-100 dark:border-white/[0.08]', + titleAdd: '添加 PaddleOCR 服务', + titleEdit: '编辑 PaddleOCR 服务', iconCls: 'fa-eye text-slate-600 dark:text-slate-400', imgIcon: '/src/assets/provider-paddleocr.svg', - btnCls: 'bg-slate-900 hover:bg-slate-800 text-white dark:bg-[#f7f8f8] dark:hover:bg-white dark:text-[#1b1b1d]', namePlaceholder: '例如:PaddleOCR 官方', urlPlaceholder: 'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs', modelPlaceholder: 'PaddleOCR-VL-1.5', @@ -64,7 +61,17 @@ const typeConfig = computed(() => ({ secretPlaceholder: '输入 API Token', urlLabel: 'API URL', }, -}[props.type])) +} + +const SHARED_ICON_BG = 'bg-gray-50 dark:bg-white/[0.04] border border-gray-100 dark:border-white/[0.08]' +const SHARED_BTN_CLS = 'bg-slate-900 hover:bg-slate-800 text-white dark:bg-[#f7f8f8] dark:hover:bg-white dark:text-[#1b1b1d]' + +const typeConfig = computed(() => ({ + ...PROVIDER_TYPE_CONFIGS[props.type], + title: isEdit.value ? PROVIDER_TYPE_CONFIGS[props.type].titleEdit : PROVIDER_TYPE_CONFIGS[props.type].titleAdd, + iconBg: SHARED_ICON_BG, + btnCls: SHARED_BTN_CLS, +})) const defaultForm = () => ({ name: '', @@ -117,9 +124,9 @@ const fetchModels = async () => { const apiModels = data.models || [] const modelSet = new Set(apiModels) // 将当前表单中的模型名也加入列表(兼容废弃模型如 deepseek-chat / deepseek-reasoner) - const currentModels = (form.value.model_name || '').split(',').map(s => s.trim()).filter(Boolean) + const currentModel = form.value.model_name || '' const currentLightModel = form.value.light_model_name || '' - for (const m of currentModels) { if (!modelSet.has(m)) { modelSet.add(m); apiModels.push(m) } } + if (currentModel && !modelSet.has(currentModel)) { modelSet.add(currentModel); apiModels.push(currentModel) } if (currentLightModel && !modelSet.has(currentLightModel)) { modelSet.add(currentLightModel); apiModels.push(currentLightModel) } modelList.value = apiModels if (modelList.value.length === 0) { @@ -203,26 +210,14 @@ const confirm = () => { emit('confirm', { ...form.value }) } -const inputCls = 'w-full rounded-xl border border-slate-200/80 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-slate-800/80 dark:text-slate-200 dark:placeholder-slate-500' - // 自定义下拉 const openDropdown = ref(null) // 'model_name' | 'light_model_name' | null const toggleDropdown = (field) => { openDropdown.value = openDropdown.value === field ? null : field } const selectOption = (field, value) => { - if (field === 'model_name') { - let current = form.value.model_name ? form.value.model_name.split(',').map(s => s.trim()).filter(Boolean) : [] - if (current.includes(value)) { - current = current.filter(m => m !== value) - } else { - current.push(value) - } - form.value.model_name = current.join(', ') - } else { - form.value[field] = value - openDropdown.value = null - } + form.value[field] = value + openDropdown.value = null } @@ -272,7 +267,7 @@ const selectOption = (field, value) => {
@@ -426,20 +419,6 @@ const selectOption = (field, value) => {