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) => {