diff --git a/.env.example b/.env.example index 4a9190f..ce1faaf 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# backend/database.py 在导入时读取本文件:仓库根 .env 优先,backend/.env 补缺, +# 已存在的进程环境变量不会被覆盖(环境变量 > .env > 代码默认值)。 +# +# 本机已经有一份可直接用的 .env(含绝对路径的 DATABASE_URL); +# 这个文件是换机器/重新部署时的模板。 + # 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db # 例:sqlite:////Users/you/nex_math/data/math.db DATABASE_URL= @@ -5,8 +11,8 @@ DATABASE_URL= # 允许跨域访问的前端地址,逗号分隔 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置) +# 可选:仅在初始化(python -m seed)建默认模型通道时使用 OPENAI_API_KEY= -# JWT 签名密钥(生产环境务必改成随机长字符串) +# JWT 签名密钥(生产环境务必改成随机长字符串;改动后所有人需重新登录) JWT_SECRET= diff --git a/README.md b/README.md index 430e1f9..2619669 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,11 @@ docker compose up --build **有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS` 是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed` -与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` / +会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` / `DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。 目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。 +**注意**:补章节只发生在初始化(`python -m seed`)时;后端每次启动只对齐 +表结构,不会再补数据,所以把自己准备好的 `math.db` 放回去后重启即原样生效。 **没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。 管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式: @@ -286,14 +288,21 @@ docker compose up --build ## 配置 -复制 `.env.example` 并按需修改(本机直接运行时使用环境变量): +后端启动时(`backend/database.py` 导入阶段)会读取 `.env`,优先级为 +**进程环境变量 > 仓库根 `.env` > `backend/.env` > 代码默认值**; +容器里 `docker-compose.yml` 注入的 `DATABASE_URL` 因此依然优先。 +本机已有一份可用的 `.env`,换机器时复制 `.env.example` 按需修改即可。 -- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db` +- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`;本机 `.env` 里写的是 + 绝对路径,直接就能看到当前实际连的库 - `CORS_ORIGINS`:允许访问的前端地址 -- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建 - “OpenAI · gpt-4o-mini”默认通道并使用该 Key; +- `OPENAI_API_KEY`:可选。只在初始化(`python -m seed`)时用来创建 + “OpenAI · gpt-4o-mini”默认通道;正常使用的模型通道请在「模型配置」里维护, `ai_teacher.py` 的逐题讲解仍为本地规则实现 -- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置 +- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置(改后所有人需要重新登录) + +**换库**:停掉后端 → 覆盖 `data/math.db` → 重启。启动只会建表 / 补列, +不会写入任何业务数据,覆盖回来的库保持原样。 ## 重置演示数据 @@ -335,5 +344,6 @@ cd backend 或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。 * 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。 * 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。 - 若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书 - (`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。 + 若库丢了,初始化时的 `seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的 + 已知电子书(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。 + (这一步只在 `python -m seed` 时执行,后端启动不再改动库与电子书文件。) diff --git a/backend/database.py b/backend/database.py index 2433b95..7d1fc93 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,7 +1,13 @@ -"""SQLite 数据库连接与会话管理。""" +"""SQLite 数据库连接与会话管理。 + +配置来源(优先级从高到低):进程环境变量 → 仓库根 ``.env`` → +``backend/.env`` → 代码里的默认值。``.env`` 在本模块导入时即被读入, +因此 ``DATABASE_URL`` / ``CORS_ORIGINS`` / ``JWT_SECRET`` 等都在此生效。 +""" from __future__ import annotations import os +import sys from pathlib import Path from sqlalchemy import create_engine, event @@ -11,6 +17,31 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_DB_PATH = REPO_ROOT / "data" / "math.db" +BACKEND_DIR = Path(__file__).resolve().parent + + +def _load_env_files() -> None: + """读取 ``.env``:仓库根优先,``backend/.env`` 只补前者没有的键。 + + 已经存在的环境变量不会被覆盖,所以容器/脚本用 ``DATABASE_URL=…`` + 直接启动时仍然以环境变量为准。 + """ + try: + from dotenv import load_dotenv + except ImportError: + # 不静默:否则 .env 会被“看起来没生效”地忽略掉 + print( + "[config] 未安装 python-dotenv,.env 不会生效。" + "请执行:backend/.venv/bin/pip install -r backend/requirements.txt", + file=sys.stderr, + ) + return + for path in (REPO_ROOT / ".env", BACKEND_DIR / ".env"): + if path.is_file(): + load_dotenv(path, override=False) + + +_load_env_files() class Base(DeclarativeBase): @@ -41,7 +72,12 @@ if DATABASE_URL.startswith("sqlite"): def ensure_schema() -> None: - """建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。""" + """建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。 + + 只做**表与字段**层面的对齐,不写任何业务数据:后端每次启动都可以安全 + 重放。种子数据与历史数据迁移只在初始化时执行 —— + ``cd backend && .venv/bin/python -m seed``。 + """ Base.metadata.create_all(bind=engine) inspector = inspect(engine) @@ -114,14 +150,13 @@ def ensure_schema() -> None: ) ) - # 补列之后再搬数据:迁移要写 knowledge_resources.course_id - _split_courses() - -def _split_courses() -> None: +def migrate_legacy_courses() -> None: """把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。 教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。 + 属于数据迁移,只在 ``python -m seed`` 初始化时执行;调用前需先跑 + ``ensure_schema()``(要用到新补的 ``knowledge_resources.course_id``)。 幂等:kind 列不存在(已迁移过)时直接返回。 """ inspector = inspect(engine) diff --git a/backend/main.py b/backend/main.py index 469acd8..010ec1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,13 @@ """数学学习系统后端入口。""" from __future__ import annotations -import json import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from sqlalchemy import text -from database import REPO_ROOT, SessionLocal, ensure_schema +from database import REPO_ROOT, ensure_schema from routers import ( analytics, archive, @@ -25,71 +23,13 @@ from routers import ( question_bank, users, ) -from seed.catalog import ensure_catalog -from services import ebooks as ebook_store -from seed.ebooks import ensure_ebooks -from seed.knowledge_graph import ensure_knowledge_graph -from seed.rbac import ensure_rbac -from services.learning_profile import ( - ensure_default_profiles, - remove_unused_admin_profile, - user_has_admin_role, -) -from services.llm_config import ensure_llm_channels -from models import AttemptItem, ErrorEntry, Question, User -from services.knowledge_service import set_question_knowledge_names +# 启动只对齐表结构(建表 / 补列),不写任何业务数据: +# 换成自己准备好的 math.db 后,重启即原样使用,不会被种子改动。 +# 种子数据与历史数据迁移只在初始化时执行: +# cd backend && .venv/bin/python -m seed [--force] ensure_schema() -db = SessionLocal() -try: - ensure_rbac(db) - ensure_knowledge_graph(db) - ensure_default_profiles(db) - for user in db.query(User).all(): - if user_has_admin_role(db, user.id): - remove_unused_admin_profile(db, user.id) - ensure_catalog(db) - ensure_ebooks(db) - ebook_store.backfill_pages(db) - ensure_llm_channels(db) - db.execute( - text( - "UPDATE questions SET used_count = (" - "SELECT COUNT(*) FROM attempt_items " - "WHERE attempt_items.question_id = questions.id" - ") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items " - "WHERE question_id IS NOT NULL)" - ) - ) - for question in db.query(Question).all(): - has_links = db.execute( - text( - "SELECT 1 FROM question_knowledge " - "WHERE question_id = :qid LIMIT 1" - ), - {"qid": question.id}, - ).first() - if has_links is None: - set_question_knowledge_names( - db, - question, - [question.knowledge_name], - attach_to_chapter=True, - ) - for row in db.query(ErrorEntry).all(): - if not row.knowledge_names or row.knowledge_names == "[]": - row.knowledge_names = json.dumps( - [row.knowledge_name], ensure_ascii=False - ) - for row in db.query(AttemptItem).all(): - if not row.knowledge_names or row.knowledge_names == "[]": - row.knowledge_names = json.dumps( - [row.knowledge_name], ensure_ascii=False - ) - db.commit() -finally: - db.close() def _cors_origins() -> list[str]: diff --git a/backend/requirements.txt b/backend/requirements.txt index ddc9ee0..e848ca2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi>=0.115,<1.0 uvicorn[standard]>=0.30 sqlalchemy>=2.0,<3.0 pydantic>=2.7,<3.0 +python-dotenv>=1.0,<2.0 httpx>=0.27,<1.0 pyjwt>=2.8,<3.0 python-multipart>=0.0.20 diff --git a/backend/routers/data.py b/backend/routers/data.py index 81ff127..84dbe91 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -7,7 +7,7 @@ from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from database import get_db +from database import SessionLocal, get_db from dependencies import require_learning_user from models import ( AttemptItem, @@ -113,20 +113,8 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict: ) if completion and completion.chapter_id is not None: chapter = db.get(Chapter, completion.chapter_id) - textbook = ( - db.get(Textbook, chapter.textbook_id) if chapter is not None else None - ) - return { - "chapter_id": chapter.id if chapter else None, - "chapter_name": chapter.name if chapter else "", - "book_name": textbook.name if textbook else "", - "rating": completion.rating, - "knowledge_names": chapter_knowledge_names(db, chapter.id) - if chapter - else [], - "reason": "今日章节已完成", - **_ebook_ref(db, chapter.id if chapter else None), - } + if chapter is not None: + return _completed_context(db, chapter, completion.rating) recommendation = build_daily_recommendation(db, user_id) or {} return { "chapter_id": recommendation.get("chapter_id"), @@ -139,6 +127,25 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict: } +def _completed_context(db: Session, chapter: Chapter, rating: str) -> dict: + """今日已完成时的上下文。 + + 直接把刚写完的章节与自评拼出来,不再依赖「写完立刻回读」—— + 复用同一个连接时 SQLite 偶尔还会停在写入前的快照上, + 响应里就会出现「已完成但自评是空的」这种自相矛盾的结果。 + """ + textbook = db.get(Textbook, chapter.textbook_id) + return { + "chapter_id": chapter.id, + "chapter_name": chapter.name, + "book_name": textbook.name if textbook else "", + "rating": rating, + "knowledge_names": chapter_knowledge_names(db, chapter.id), + "reason": "今日章节已完成", + **_ebook_ref(db, chapter.id), + } + + def _streak(done_dates: set[date]) -> int: today = date.today() if today not in done_dates and today - timedelta(days=1) not in done_dates: @@ -413,15 +420,23 @@ def complete_daily( exists.chapter_id = chapter.id exists.rating = payload.rating db.commit() - done_dates = { - row.done_on - for row in db.query(DailyCompletion) - .filter(DailyCompletion.user_id == user.id) - .all() - } + # 写完用新会话回读连续天数:同一个 session 复用连接时可能读到写入前的快照, + # 会让响应里的连续天数与自评对不上(前端就会显示成提交没生效)。 + read_db = SessionLocal() + try: + done_dates = { + row.done_on + for row in read_db.query(DailyCompletion) + .filter(DailyCompletion.user_id == user.id) + .all() + } + finally: + read_db.close() + # 今天这一条就是本次写入的,不依赖回读结果 + done_dates.add(today) return DailyOut( today=today, completed=True, streak=_streak(done_dates), - **_daily_context(db, user.id, today), + **_completed_context(db, chapter, payload.rating), ) diff --git a/backend/routers/knowledge_graph.py b/backend/routers/knowledge_graph.py index 301c442..326f06d 100644 --- a/backend/routers/knowledge_graph.py +++ b/backend/routers/knowledge_graph.py @@ -6,7 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from database import get_db -from dependencies import require_permission +from dependencies import require_learning_user, require_permission from models import ( ChapterKnowledge, Course, @@ -22,12 +22,15 @@ from schemas import ( KnowledgeGraphEdge, KnowledgeGraphNode, KnowledgeGraphOut, + KnowledgeMapNode, + KnowledgeMapOut, KnowledgeNodeCreate, KnowledgeNodeUpdate, KnowledgeRelationIn, KnowledgeResourceIn, KnowledgeResourceOut, ) +from services.mastery_engine import status_for from services.knowledge_service import knowledge_resources @@ -98,6 +101,69 @@ def get_graph( ) +@router.get("/map", response_model=KnowledgeMapOut) +def get_knowledge_map( + db: Session = Depends(get_db), + user: User = Depends(require_learning_user), +): + """学生视角的完整知识图谱:全量结构 + 本人掌握度。 + + /knowledge 只返回练过的知识点,画不出一张完整的地图; + 这里一次给全量节点(含未练过的)与全部关系,计数用分组查询,避免逐点 N+1。 + """ + nodes = ( + db.query(Knowledge) + .order_by(Knowledge.domain, Knowledge.category, Knowledge.id) + .all() + ) + edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all() + mastery = { + row.knowledge_id: row.mastery + for row in db.query(UserKnowledge) + .filter(UserKnowledge.user_id == user.id) + .all() + } + question_counts = dict( + db.query(QuestionKnowledge.knowledge_id, func.count(QuestionKnowledge.id)) + .group_by(QuestionKnowledge.knowledge_id) + .all() + ) + chapter_counts = dict( + db.query(ChapterKnowledge.knowledge_id, func.count(ChapterKnowledge.id)) + .group_by(ChapterKnowledge.knowledge_id) + .all() + ) + + def count(mapping: dict, node_id: int) -> int: + return int(mapping.get(node_id, 0) or 0) + + return KnowledgeMapOut( + nodes=[ + KnowledgeMapNode( + id=node.id, + name=node.name, + domain=node.domain, + category=node.category, + description=node.description, + mastery=mastery.get(node.id, 0), + status=status_for(mastery.get(node.id, 0)), + practiced=node.id in mastery, + question_count=count(question_counts, node.id), + chapter_count=count(chapter_counts, node.id), + ) + for node in nodes + ], + edges=[ + KnowledgeGraphEdge( + source=edge.source_id, + target=edge.target_id, + relation_type=edge.relation_type, + ) + for edge in edges + ], + ) + + @router.post("/nodes", response_model=KnowledgeGraphNode) def create_node( payload: KnowledgeNodeCreate, diff --git a/backend/routers/question_bank.py b/backend/routers/question_bank.py index 49b49a1..4f10141 100644 --- a/backend/routers/question_bank.py +++ b/backend/routers/question_bank.py @@ -5,6 +5,7 @@ import json import uuid from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func from sqlalchemy.orm import Session from database import get_db @@ -19,7 +20,12 @@ from models import ( Textbook, User, ) -from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate +from schemas import ( + BankSummaryOut, + GenerateIn, + QuestionBankItemOut, + QuestionUpdate, +) from services.question_generator import generate_questions from services.llm_config import get_default_channel from services.knowledge_service import ( @@ -141,6 +147,27 @@ def list_questions( return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows] +@router.get("/summary", response_model=BankSummaryOut) +def summary( + db: Session = Depends(get_db), + _: User = Depends(require_permission("question-bank:manage")), +): + "题库侧栏计数:总数、未挂章节、AI 生成、已被使用,一次算完。" + + def count(*conditions) -> int: + query = db.query(func.count(Question.id)) + for condition in conditions: + query = query.filter(condition) + return int(query.scalar() or 0) + + return BankSummaryOut( + total=count(), + unattached=count(Question.chapter_id.is_(None)), + generated=count(Question.is_generated.is_(True)), + used=count(Question.used_count > 0), + ) + + @router.delete("/questions/{question_id}") def delete_question( question_id: int, diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py index 7d7d3ff..b86e655 100644 --- a/backend/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -303,6 +303,14 @@ class QuestionBankItemOut(BaseModel): knowledge_ids: list[int] = [] +class BankSummaryOut(BaseModel): + "侧栏计数用:全库题目总数与几个切片,避免把整库题目拉回前端数数。" + total: int + unattached: int + generated: int + used: int + + class QuestionUpdate(BaseModel): stem: str = Field(min_length=1) options: list[str] = Field(min_length=4, max_length=4) @@ -541,6 +549,26 @@ class KnowledgeGraphOut(BaseModel): edges: list[KnowledgeGraphEdge] = [] +class KnowledgeMapNode(BaseModel): + """学生视角的知识节点:结构 + 本人掌握度,一次给全量。""" + id: int + name: str + domain: str + category: str + description: str = "" + mastery: int = 0 + status: str = "重点" + # 有没有练过:没练过的不该被当成「薄弱」,界面上单独表达 + practiced: bool = False + chapter_count: int = 0 + question_count: int = 0 + + +class KnowledgeMapOut(BaseModel): + nodes: list[KnowledgeMapNode] = [] + edges: list[KnowledgeGraphEdge] = [] + + class KnowledgeNodeCreate(BaseModel): name: str = Field(min_length=1, max_length=64) domain: str = "初等" diff --git a/backend/seed/__main__.py b/backend/seed/__main__.py index 43f7432..9e094bb 100644 --- a/backend/seed/__main__.py +++ b/backend/seed/__main__.py @@ -2,6 +2,10 @@ 用法:在 backend/ 目录下执行 python -m seed [--force] --force 会清空全部学习数据与题库后重新导入,保留用户与角色。 + +这里是**唯一**会补种数据的地方:后端每次启动只对齐表结构 +(``database.ensure_schema``),不再补章节 / 知识点 / 教材等业务数据, +因此自己准备的 math.db 覆盖回来后不会被种子改动。 """ from __future__ import annotations @@ -9,7 +13,14 @@ import argparse import json from datetime import date, timedelta -from database import DATABASE_URL, SessionLocal, ensure_schema +from sqlalchemy import text + +from database import ( + DATABASE_URL, + SessionLocal, + ensure_schema, + migrate_legacy_courses, +) from models import ( AttemptItem, AttemptSession, @@ -22,7 +33,9 @@ from models import ( from seed.catalog import ensure_catalog from seed.data import KNOWLEDGE, QUESTIONS from seed.ebooks import ensure_ebooks +from seed.knowledge_graph import ensure_knowledge_graph from seed.rbac import DEFAULT_USERS, ensure_rbac +from services import ebooks as ebook_store from services.learning_profile import ( ensure_default_profiles, remove_unused_admin_profile, @@ -116,12 +129,58 @@ def _demo_history(db, student: User) -> None: db.add(DailyCompletion(user_id=student.id, done_on=date.today() - timedelta(days=1))) +def _backfill_derived_fields(db) -> None: + """给存量数据补派生字段(题目使用次数、知识点名快照)。 + + 以前这些 UPDATE 挂在后端启动流程里,现在只属于初始化: + 自己准备好的库覆盖回来后不会被重新计算 / 覆盖。 + """ + db.execute( + text( + "UPDATE questions SET used_count = (" + "SELECT COUNT(*) FROM attempt_items " + "WHERE attempt_items.question_id = questions.id" + ") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items " + "WHERE question_id IS NOT NULL)" + ) + ) + for question in db.query(Question).all(): + has_links = db.execute( + text( + "SELECT 1 FROM question_knowledge " + "WHERE question_id = :qid LIMIT 1" + ), + {"qid": question.id}, + ).first() + if has_links is None: + set_question_knowledge_names( + db, + question, + [question.knowledge_name], + attach_to_chapter=True, + ) + for row in db.query(ErrorEntry).all(): + if not row.knowledge_names or row.knowledge_names == "[]": + row.knowledge_names = json.dumps( + [row.knowledge_name], ensure_ascii=False + ) + for row in db.query(AttemptItem).all(): + if not row.knowledge_names or row.knowledge_names == "[]": + row.knowledge_names = json.dumps( + [row.knowledge_name], ensure_ascii=False + ) + db.commit() + + def load_seed(force: bool = False) -> bool: ensure_schema() + # 补列之后再搬数据:迁移要写 knowledge_resources.course_id + migrate_legacy_courses() db = SessionLocal() created = False try: ensure_rbac(db) + ensure_knowledge_graph(db) users = { username: db.query(User).filter(User.username == username).first() for username, _, _, _ in DEFAULT_USERS @@ -164,6 +223,8 @@ def load_seed(force: bool = False) -> bool: ensure_catalog(db, force_sync=force) # 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好 ensure_ebooks(db) + ebook_store.backfill_pages(db) + _backfill_derived_fields(db) for user in users.values(): if user and user_has_admin_role(db, user.id): remove_unused_admin_profile(db, user.id) diff --git a/data/ebooks/7/8dc7e48036ac4db8abf0020334b47b07.pdf b/data/ebooks/11/8dc7e48036ac4db8abf0020334b47b07.pdf similarity index 100% rename from data/ebooks/7/8dc7e48036ac4db8abf0020334b47b07.pdf rename to data/ebooks/11/8dc7e48036ac4db8abf0020334b47b07.pdf diff --git a/frontend/index.html b/frontend/index.html index 6a769da..0a147f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + 数学学习系统 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..c3cf4fe --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + ∑ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff16261..3d7c2e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -156,7 +156,7 @@ function pageFor( case "errors": return ; case "records": - return ; + return ; case "daily": return ( + request("/api/question-bank/summary"), deleteQuestion: (id: number) => request<{ deleted: number }>(`/api/question-bank/questions/${id}`, { method: "DELETE", @@ -355,6 +359,7 @@ export const api = { request(`/api/llm/tasks/${id}`), knowledgeGraph: () => request("/api/knowledge/graph"), + knowledgeMap: () => request("/api/knowledge/map"), createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) => request("/api/knowledge/nodes", { method: "POST", diff --git a/frontend/src/components/KnowledgeGraphView.tsx b/frontend/src/components/KnowledgeGraphView.tsx new file mode 100644 index 0000000..11c8e2f --- /dev/null +++ b/frontend/src/components/KnowledgeGraphView.tsx @@ -0,0 +1,483 @@ +/** + * 知识图谱画布:把「域 → 分类 → 知识点」画成一张可以走的地图。 + * + * 布局用路线图(roadmap)的思路,不用力导向: + * · 一个分类一列,列头是分类本身,知识点在列内自上而下排; + * · 列与列之间横向滚动,同屏能看清一个域的全部骨架; + * · 前置 / 相关关系画成曲线,跨列的连线就是这张图的“骨架”; + * · 选中一个点,它和一跳邻居亮起、其余淡出,看关系不用自己找线。 + * + * 颜色可切两种语义:掌握度(学生)/ 结构(管理员),除此之外完全一致。 + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; +import { Button, Empty, Tooltip, Typography } from "antd"; +import { + AimOutlined, + MinusOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { masteryBandColor, masteryProgressColor } from "../theme"; +import { useT } from "../prefs"; +import type { MsgKey } from "../i18n"; +import type { KnowledgeGraphEdge } from "../types"; + +export interface GraphViewNode { + id: number; + name: string; + domain: string; + category: string; + mastery?: number | null; + status?: string | null; + /** 没练过的点单独表达,不混进「重点」 */ + practiced?: boolean; + question_count?: number; + chapter_count?: number; +} + +interface KnowledgeGraphViewProps { + nodes: GraphViewNode[]; + edges: KnowledgeGraphEdge[]; + selectedId?: number | null; + onSelect?: (node: GraphViewNode) => void; + /** 这些节点淡出(掌握度档位筛选等),布局不变 */ + dimmedIds?: Set; + /** 按掌握度着色;关闭时是纯结构视图 */ + showMastery?: boolean; + height?: number; + /** 域标题的取词(学生端与管理端文案一致,允许覆盖) */ + domainLabel?: (domain: string) => string; +} + +const COL_W = 196; +const COL_GAP = 76; +const HEAD_H = 40; +const ROW_H = 46; +const CARD_H = 34; +const TOP_PAD = 62; +const LEFT_PAD = 18; +const SCALE_MIN = 0.55; +const SCALE_MAX = 1.5; + +const ROOT_NAMES = new Set(["初等数学", "高等数学"]); + +function isRoot(node: GraphViewNode): boolean { + return node.category === "总纲" || ROOT_NAMES.has(node.name); +} + +interface Placed { + node: GraphViewNode; + x: number; + y: number; +} + +interface CategoryColumn { + key: string; + domain: string; + category: string; + /** 分类节点自己(可能不存在) */ + head?: GraphViewNode; + x: number; + points: Placed[]; + mastery: number | null; + practicedCount: number; + questionCount: number; +} + +interface Layout { + columns: CategoryColumn[]; + placed: Map; + width: number; + height: number; + /** 每个域第一条列的 x 与覆盖宽度,用来画域标题条 */ + domainBands: { domain: string; x: number; width: number }[]; +} + +/** + * 计算布局。纯函数,输入相同则坐标稳定——不会像力导向那样每次刷新都乱跳。 + */ +function buildLayout(nodes: GraphViewNode[]): Layout { + const rest = nodes.filter((node) => !isRoot(node)); + const categoryOrder: { domain: string; category: string }[] = []; + rest.forEach((node) => { + const key = `${node.domain}//${node.category || "未分类"}`; + if (!categoryOrder.some((item) => `${item.domain}//${item.category || "未分类"}` === key)) { + categoryOrder.push({ domain: node.domain, category: node.category || "未分类" }); + } + }); + + const columns: CategoryColumn[] = categoryOrder.map((item, index) => { + const inColumn = rest.filter( + (node) => node.domain === item.domain && (node.category || "未分类") === item.category, + ); + const head = inColumn.find((node) => node.name === item.category); + const points = inColumn.filter((node) => node !== head); + const practiced = points.filter((node) => node.practiced); + return { + key: `${item.domain}//${item.category}`, + domain: item.domain, + category: item.category, + head, + x: LEFT_PAD + index * (COL_W + COL_GAP), + points: points.map((node, row) => ({ + node, + x: LEFT_PAD + index * (COL_W + COL_GAP), + y: TOP_PAD + HEAD_H + 16 + row * ROW_H, + })), + mastery: practiced.length + ? Math.round( + practiced.reduce((sum, node) => sum + (node.mastery ?? 0), 0) / + practiced.length, + ) + : null, + practicedCount: practiced.length, + questionCount: points.reduce((sum, node) => sum + (node.question_count ?? 0), 0), + }; + }); + + const placed = new Map(); + columns.forEach((column) => { + column.points.forEach((item) => placed.set(item.node.id, item)); + }); + + const tallest = columns.reduce( + (max, column) => Math.max(max, column.points.length * ROW_H), + 0, + ); + const rows: { domain: string; x: number; width: number }[] = []; + columns.forEach((column) => { + const last = rows[rows.length - 1]; + if (last && last.domain === column.domain) { + last.width = column.x + COL_W - last.x; + } else { + rows.push({ domain: column.domain, x: column.x, width: COL_W }); + } + }); + + return { + columns, + placed, + width: LEFT_PAD + columns.length * (COL_W + COL_GAP), + height: TOP_PAD + HEAD_H + 16 + tallest + 24, + domainBands: rows, + }; +} + +/** 一条边的几何:列内绕右侧走一段,跨列走水平贝塞尔。 */ +function edgePath(from: Placed, to: Placed): string { + const fromY = from.y + CARD_H / 2; + const toY = to.y + CARD_H / 2; + if (from.x === to.x) { + const bulge = from.x + COL_W + 26; + return `M ${from.x + COL_W - 6} ${fromY} C ${bulge} ${fromY}, ${bulge} ${toY}, ${from.x + COL_W - 6} ${toY}`; + } + const forward = to.x > from.x; + const startX = forward ? from.x + COL_W : from.x; + const endX = forward ? to.x : to.x + COL_W; + const midX = (startX + endX) / 2; + return `M ${startX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${endX} ${toY}`; +} + +const EDGE_LABEL: Record = { + 前置: "graph.relPrereq", + 相关: "graph.relRelated", + 包含: "graph.relContains", +}; + +export default function KnowledgeGraphView({ + nodes, + edges, + selectedId, + onSelect, + dimmedIds, + showMastery = false, + height = 560, + domainLabel, +}: KnowledgeGraphViewProps) { + const t = useT(); + const [scale, setScale] = useState(1); + const [hoverId, setHoverId] = useState(null); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null); + const scrollerRef = useRef(null); + + const layout = useMemo(() => buildLayout(nodes), [nodes]); + + // 选中/悬停的一跳邻居:关系一眼看得见,不需要自己顺着线找 + const focusId = hoverId ?? selectedId ?? null; + const neighbours = useMemo(() => { + if (focusId === null) return null; + const ids = new Set([focusId]); + edges.forEach((edge) => { + if (edge.source === focusId) ids.add(edge.target); + if (edge.target === focusId) ids.add(edge.source); + }); + const node = nodes.find((item) => item.id === focusId); + if (node) { + nodes.forEach((item) => { + if (item.category === node.category && item.domain === node.domain) ids.add(item.id); + if (item.name === node.category) ids.add(item.id); + }); + } + return ids; + }, [focusId, edges, nodes]); + + const drawnEdges = useMemo( + () => + edges + .map((edge) => { + const from = layout.placed.get(edge.source); + const to = layout.placed.get(edge.target); + if (!from || !to) return null; + return { edge, from, to, d: edgePath(from, to) }; + }) + .filter((item): item is { edge: KnowledgeGraphEdge; from: Placed; to: Placed; d: string } => + Boolean(item), + ), + [edges, layout], + ); + + useEffect(() => { + setOffset({ x: 0, y: 0 }); + }, [nodes.length, showMastery]); + + if (nodes.length === 0) { + return ; + } + + const zoom = (delta: number) => + setScale((value) => + Math.min(SCALE_MAX, Math.max(SCALE_MIN, Number((value + delta).toFixed(2)))), + ); + + function startDrag(event: ReactPointerEvent) { + dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }; + (event.target as HTMLElement).setPointerCapture?.(event.pointerId); + } + + function moveDrag(event: ReactPointerEvent) { + const drag = dragRef.current; + if (!drag) return; + setOffset({ + x: drag.ox + (event.clientX - drag.x), + y: drag.oy + (event.clientY - drag.y), + }); + } + + function endDrag() { + dragRef.current = null; + } + + const nodeClass = (node: GraphViewNode) => { + const classes = ["kg-node"]; + if (node.id === selectedId) classes.push("kg-node--selected"); + if (dimmedIds?.has(node.id)) classes.push("kg-node--dim"); + else if (neighbours && !neighbours.has(node.id)) classes.push("kg-node--faded"); + if (showMastery) { + if (node.practiced) classes.push("kg-node--practiced"); + else classes.push("kg-node--unpracticed"); + } + return classes.join(" "); + }; + + return ( +
+
+ {t("graph.canvasHint")} + +
+
+
+ + + {layout.domainBands.map((band) => ( +
+ + {domainLabel ? domainLabel(band.domain) : band.domain} + +
+ ))} + + {layout.columns.map((column) => ( +
column.head && onSelect?.(column.head)} + role={column.head ? "button" : undefined} + tabIndex={column.head ? 0 : undefined} + > + {column.category} + + {showMastery && column.mastery !== null + ? `${column.mastery}%` + : column.questionCount > 0 + ? t("graph.columnQuestions", { n: column.questionCount }) + : ""} + +
+ ))} + + {layout.columns.flatMap((column) => + column.points.map((item) => { + const node = item.node; + const tone = + showMastery && node.practiced + ? masteryBandColor(node.status ?? "") + : undefined; + return ( + +
onSelect?.(node)} + onMouseEnter={() => setHoverId(node.id)} + onMouseLeave={() => setHoverId((value) => (value === node.id ? null : value))} + role="button" + tabIndex={0} + > + + {node.name} + {showMastery && node.practiced && ( + + {node.mastery}% + + )} +
+
+ ); + }), + )} +
+
+
+ {showMastery ? ( + <> + {["重点", "待加强", "基本掌握", "已掌握"].map((band) => ( + + + {band} + + ))} + + + {t("graph.notPracticed")} + + + ) : ( + + {t("graph.edgeLegend")} + + )} +
+
+ ); +} diff --git a/frontend/src/components/Options.tsx b/frontend/src/components/Options.tsx index 559839f..c76d1ec 100644 --- a/frontend/src/components/Options.tsx +++ b/frontend/src/components/Options.tsx @@ -23,13 +23,13 @@ export function OptionPicker({ options, value, onChange }: OptionPickerProps) { return ( ); })} diff --git a/frontend/src/components/Paper.tsx b/frontend/src/components/Paper.tsx new file mode 100644 index 0000000..52287a0 --- /dev/null +++ b/frontend/src/components/Paper.tsx @@ -0,0 +1,364 @@ +/** + * 试卷排版原子组件:题号、题干、答题卡、逐题定位。 + * + * 目标是让屏幕上的内容与纸质试卷的读图顺序一致: + * 题号在最左,题干成段,选项竖排,答案与解析跟在题后, + * 顶部常驻一张答题卡负责「我现在在哪一题、哪些题还没做」。 + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Button, Flex, Tag, Tooltip, Typography } from "antd"; +import { + CheckCircleFilled, + CloseCircleFilled, + LeftOutlined, + MinusOutlined, + RightOutlined, +} from "@ant-design/icons"; +import { tagColor } from "../theme"; +import { useT } from "../prefs"; + +/** 题目在答题卡上的状态语义。 */ +export type PaperStatus = "correct" | "wrong" | "done" | "pending" | "neutral"; + +/** 答题卡每页题号数量:题量一大就不再把几十个题号铺成一片。 */ +export const PAPER_PAGE_SIZE = 10; + +export const paperAnchorAttr = (key: string | number) => `paper-${key}`; + +/** + * 定位当前可见的题目:滚动时找最后一道已经滚到顶部的题。 + * 用 IntersectionObserver 在长文里有跳变,直接用偏移量更稳。 + */ +export function useActiveAnchor( + containerRef: React.RefObject, + keys: (string | number)[], + revision: unknown = 0, +): string | number | null { + const [active, setActive] = useState(keys[0] ?? null); + const frame = useRef(0); + + const sync = useCallback(() => { + const container = containerRef.current; + if (!container) return; + const nodes = Array.from( + container.querySelectorAll("[data-paper-anchor]"), + ); + const top = container.scrollTop + 96; + let current: string | number | null = nodes[0]?.dataset.paperAnchor ?? null; + nodes.forEach((node) => { + if (node.offsetTop <= top) current = node.dataset.paperAnchor ?? current; + }); + // DOM 里存的是 `paper-`,答题卡按原始 key 比较:先脱掉前缀, + // 再还原成调用方传入的那个 key(数字 key 变成字符串就高亮不上了) + const bare = + current === null ? null : String(current).replace(/^paper-/, ""); + const next = + bare === null + ? null + : (keys.find((key) => String(key) === bare) ?? bare); + setActive((prev) => { + const prevKey = prev === null ? null : String(prev); + return prevKey === (next === null ? null : String(next)) ? prev : next; + }); + }, [containerRef, keys]); + + // 翻页 / 换一批题之后,当前高亮必须落在新的题号集合里 + useEffect(() => { + setActive((prev) => + prev !== null && keys.some((key) => String(key) === String(prev)) + ? prev + : (keys[0] ?? null), + ); + }, [keys]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + const schedule = () => { + cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(sync); + }; + schedule(); + container.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(container); + return () => { + cancelAnimationFrame(frame.current); + container.removeEventListener("scroll", schedule); + observer.disconnect(); + }; + }, [containerRef, sync, revision, keys.length]); + + return active; +} + +/** 滚动到某一道题(答题卡点击)。 */ +export function scrollToQuestion( + container: HTMLElement | null, + key: string | number, +) { + if (!container) return; + const node = container.querySelector( + `[data-paper-anchor="paper-${key}"]`, + ); + if (!node) return; + container.scrollTo({ top: node.offsetTop - 16, behavior: "smooth" }); +} + +interface AnswerSheetItem { + key: string | number; + label?: ReactNode; + status: PaperStatus; + hint?: ReactNode; +} + +interface AnswerSheetProps { + items: AnswerSheetItem[]; + activeKey?: string | number | null; + onSelect?: (key: string | number) => void; + /** 右侧统计(已答 3/5、正确率 80% 等) */ + summary?: ReactNode; + /** 统计与筛选之间的插槽 */ + extra?: ReactNode; + /** 每页题号数量:大于 0 且题号更多时按页展示,并出现翻页控件 */ + pageSize?: number; + /** 受控页码(从 0 开始);不传时组件自己维护当前页 */ + page?: number; + onPageChange?: (page: number) => void; + size?: "default" | "small"; +} + +/** + * 答题卡:题号即导航。颜色直接复用判分语义, + * 一眼能看出哪几题错了、哪几题还没做。 + * + * 题号多时按 `pageSize` 分页:一页只列这一段题号, + * 右侧给出「当前区间 / 总题数」与翻页按钮,避免定位器拉成一长条。 + */ +export function AnswerSheet({ + items, + activeKey, + onSelect, + summary, + extra, + pageSize, + page, + onPageChange, + size = "default", +}: AnswerSheetProps) { + const t = useT(); + const [innerPage, setInnerPage] = useState(0); + const paged = Boolean(pageSize && pageSize > 0); + const pageCount = paged + ? Math.max(1, Math.ceil(items.length / (pageSize as number))) + : 1; + const current = Math.max( + 0, + Math.min(page === undefined ? innerPage : page, pageCount - 1), + ); + const start = paged ? current * (pageSize as number) : 0; + const visible = paged ? items.slice(start, start + (pageSize as number)) : items; + + function goTo(next: number) { + const target = Math.max(0, Math.min(next, pageCount - 1)); + if (page === undefined) setInnerPage(target); + onPageChange?.(target); + } + + return ( +
+
+ {visible.map((item) => { + const active = String(activeKey) === String(item.key); + const chip = ( + + ); + return item.hint ? ( + + {chip} + + ) : ( + chip + ); + })} +
+ {pageCount > 1 && ( +
+
+ )} + {extra} + {summary &&
{summary}
} +
+ ); +} + +interface PaperQuestionProps { + /** 题号(从 1 开始) */ + index: number; + anchor?: string | number; + status?: PaperStatus; + statusLabel?: ReactNode; + /** 知识点、难度等标签 */ + tags?: ReactNode; + actions?: ReactNode; + stem?: ReactNode; + /** 题干下方、答案上方的主体(选项、作答控件) */ + children?: ReactNode; + /** 答案与解析区 */ + footer?: ReactNode; + active?: boolean; + className?: string; +} + +/** 试卷上的一道题:题号 + 题干 + 主体 + 答案区,段落节奏与纸质卷一致。 */ +export function PaperQuestion({ + index, + anchor, + status, + statusLabel, + tags, + actions, + stem, + children, + footer, + active, + className, +}: PaperQuestionProps) { + return ( +
+
+ {index} +
+
+ + {statusLabel && {statusLabel}} + {tags} + {actions && {actions}} + + {stem &&
{stem}
} + {children &&
{children}
} + {footer &&
{footer}
} +
+
+ ); +} + +function statusTagColor(status?: PaperStatus): string { + if (status === "correct") return tagColor.positive; + if (status === "wrong") return tagColor.negative; + if (status === "pending") return tagColor.warning; + return tagColor.neutral; +} + +/** 作答对照行:我的答案 / 正确答案,答对时只留一条。 */ +export function AnswerCompare({ + yourIndex, + correctIndex, + correct, + letter, +}: { + yourIndex?: number | null; + correctIndex?: number | null; + correct?: boolean | null; + letter: (index: number) => string; +}) { + const t = useT(); + if (yourIndex === null || yourIndex === undefined) { + return ( + {t("paper.unanswered")} + ); + } + if (correct) { + return ( + + + {t("paper.yourAnswerCorrect", { answer: letter(yourIndex) })} + + + ); + } + return ( + + + {t("paper.yourAnswer", { answer: letter(yourIndex) })} + + {(correctIndex ?? null) !== null && ( + + {t("paper.correctAnswer", { answer: letter(correctIndex as number) })} + + )} + + ); +} + +/** 解析折叠块:默认展开,答对的题可以只留一行。 */ +export function ExplanationBlock({ + content, + defaultOpen = true, +}: { + content: ReactNode; + defaultOpen?: boolean; +}) { + const t = useT(); + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{content}
} +
+ ); +} diff --git a/frontend/src/components/PaperReview.tsx b/frontend/src/components/PaperReview.tsx new file mode 100644 index 0000000..5ebd21f --- /dev/null +++ b/frontend/src/components/PaperReview.tsx @@ -0,0 +1,341 @@ +/** + * 试卷回看:测试记录、错题本、测试结果共用同一套排版。 + * + * 与纸质卷一致:从上往下是「卷头 → 答题卡 → 逐题」, + * 顶部常驻答题卡可以点题号直接跳题,答错的题可以只筛出来复盘。 + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Alert, Button, Flex, Segmented, Switch, Tag, Typography } from "antd"; +import RichText from "./RichText"; +import { + AnswerCompare, + AnswerSheet, + PAPER_PAGE_SIZE, + PaperQuestion, + scrollToQuestion, + useActiveAnchor, +} from "./Paper"; +import { OptionList, optionLetter } from "./Options"; +import { EmptyState, KnowledgeTags } from "./ui"; +import { useT } from "../prefs"; +import { tagColor } from "../theme"; + +export interface ReviewItem { + key: string | number; + stem?: string | null; + options?: string[] | null; + selected?: number | null; + correctIndex?: number | null; + explanation?: string | null; + /** null 表示这套题没有逐题判分(例如题库预览) */ + correct?: boolean | null; + knowledgeNames?: string[] | null; + /** 附加标签(来源章节、诊断结论…) */ + tags?: ReactNode; + /** 卡片右上角操作(编辑、去练习…) */ + actions?: ReactNode; +} + +export interface PaperScoreSummary { + score: number; + total: number; + accuracy?: number; +} + +interface PaperReviewProps { + items: ReviewItem[]; + /** 卷头标题:哪一次的测试、哪一章 */ + title?: ReactNode; + /** 标题下的次要信息:日期、来源、知识点 */ + meta?: ReactNode; + score?: PaperScoreSummary | null; + /** 右侧操作(再做一套、去教材…) */ + actions?: ReactNode; + empty?: ReactNode; + /** 旧数据没有逐题明细时的提示 */ + legacyHint?: string; + /** 题库预览:没有作答也要标出正确答案 */ + revealAnswers?: boolean; + /** 卷头右侧的页面控件(视图切换、批量操作…) */ + tools?: ReactNode; +} + +type ReviewFilter = "all" | "wrong"; + +/** 把回看数据折算成答题卡状态。 */ +function reviewStatus(correct?: boolean | null) { + if (correct === true) return "correct" as const; + if (correct === false) return "wrong" as const; + return "neutral" as const; +} + +export function PaperReview({ + items, + title, + meta, + score, + actions, + empty, + legacyHint, + revealAnswers, + tools, +}: PaperReviewProps) { + const t = useT(); + const bodyRef = useRef(null); + const [filter, setFilter] = useState("all"); + const [expandAll, setExpandAll] = useState(false); + const [page, setPage] = useState(0); + + const hasVerdict = items.some((item) => item.correct === true || item.correct === false); + const wrongCount = items.filter((item) => item.correct === false).length; + const visible = useMemo( + () => (filter === "wrong" ? items.filter((item) => item.correct === false) : items), + [items, filter], + ); + /** 卷子按页拆开:题号定位器与卷面正文共用同一个页码。 */ + const pageCount = Math.max(1, Math.ceil(visible.length / PAPER_PAGE_SIZE)); + const currentPage = Math.min(page, pageCount - 1); + const pageStart = currentPage * PAPER_PAGE_SIZE; + const pageItems = useMemo( + () => visible.slice(pageStart, pageStart + PAPER_PAGE_SIZE), + [visible, pageStart], + ); + const anchors = useMemo(() => pageItems.map((item) => item.key), [pageItems]); + const active = useActiveAnchor( + bodyRef, + anchors, + `${filter}-${items.length}-${currentPage}`, + ); + + // 换章节 / 换记录 / 换筛选后回到第一页;翻页后回到卷首 + const scopeKey = `${items.length}:${items[0]?.key ?? ""}:${filter}`; + useEffect(() => { + setPage(0); + }, [scopeKey]); + + useEffect(() => { + bodyRef.current?.scrollTo({ top: 0 }); + }, [currentPage, filter]); + + return ( +
+
+ +
+
{title}
+ {meta &&
{meta}
} +
+ + {score && ( +
+ + {score.score} + /{score.total} + + {t("paper.score")} +
+ )} + {actions} + {tools} +
+
+
+ ({ + key: item.key, + label: index + 1, + status: reviewStatus(item.correct), + }))} + activeKey={active} + onSelect={(key) => scrollToQuestion(bodyRef.current, key)} + page={currentPage} + pageSize={PAPER_PAGE_SIZE} + onPageChange={setPage} + size="small" + /> + + {hasVerdict && wrongCount > 0 && ( + setFilter(value as ReviewFilter)} + options={[ + { value: "all", label: t("paper.filterAll", { n: items.length }) }, + { value: "wrong", label: t("paper.filterWrong", { n: wrongCount }) }, + ]} + /> + )} + + + {t("paper.expandExplanations")} + + + + +
+
+ +
+ {items.length === 0 ? ( + empty ?? + ) : ( + pageItems.map((item) => { + const originalIndex = items.indexOf(item); + const verdict = reviewStatus(item.correct); + return ( + + + {item.tags} + + } + actions={item.actions} + stem={ + item.stem ? ( + + ) : ( + + ) + } + footer={ + item.stem ? ( + <> + + {item.correct === null || item.correct === undefined ? ( + + {(item.correctIndex ?? null) !== null && ( + + {t("paper.correctAnswer", { + answer: optionLetter(item.correctIndex as number), + })} + + )} + + ) : ( + + )} + {item.explanation && !expandAll && item.correct !== false && ( + setExpandAll(true)} + > + {t("paper.showExplanation")} + + )} + + {(expandAll || item.correct === false) && item.explanation && ( +
+ +
+ )} + + ) : null + } + > + {item.options && item.options.length > 0 && ( +
+ +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +/** 卷头常用的一段说明文字。 */ +export function PaperMetaText({ children }: { children: ReactNode }) { + return {children}; +} + +/** 得分与正确率的横条,供记录列表侧栏复用。 */ +export function ScoreChip({ + correct, + total, +}: { + correct: number; + total: number; +}) { + const t = useT(); + const wrong = total - correct; + if (total === 0) { + return {t("paper.emptyPaper")}; + } + if (wrong === 0) { + return ( + + {t("records.allCorrect")} + + ); + } + return ( + + {t("records.wrongN", { n: wrong })} + + ); +} + +/** 空的试卷占位(未选择记录 / 未选择题目标时)。 */ +export function PaperPlaceholder({ + description, + action, +}: { + description: ReactNode; + action?: ReactNode; +}) { + return ( +
+
+ +
+
+ ); +} + +/** 次要按钮的简写,页面里回看/跳转动作会反复用到。 */ +export function PaperLinkButton({ + children, + onClick, + disabled, +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/frontend/src/components/PracticeResult.tsx b/frontend/src/components/PracticeResult.tsx index ef79636..7a36657 100644 --- a/frontend/src/components/PracticeResult.tsx +++ b/frontend/src/components/PracticeResult.tsx @@ -1,15 +1,19 @@ -import { - CheckCircleOutlined, - CloseCircleOutlined, - DashboardOutlined, - ReloadOutlined, -} from "@ant-design/icons"; -import { Alert, Button, Flex, Space, Tag } from "antd"; +/** + * 测试结果:左侧成绩单,右侧逐题回看。 + * + * 判分之后最重要的是「错在哪一题」, + * 所以成绩与知识点变化收在左栏,右栏用和测试记录完全一样的试卷版式复盘。 + */ +import { useMemo, useRef } from "react"; +import { Button, Flex, Progress, Tag, Typography } from "antd"; +import { DashboardOutlined, ReloadOutlined } from "@ant-design/icons"; import type { PracticeSession, SubmitResult } from "../types"; +import { AnswerCompare, AnswerSheet, PaperQuestion, scrollToQuestion } from "./Paper"; +import { OptionList, optionLetter } from "./Options"; +import { Pane, SplitView } from "./SplitView"; import RichText from "./RichText"; -import { DataRow, DataRows, Panel, SectionTitle } from "./ui"; -import { status, tagColor } from "../theme"; import { useT } from "../prefs"; +import { masteryTagColor, tagColor } from "../theme"; interface PracticeResultProps { session: PracticeSession; @@ -25,53 +29,164 @@ export function PracticeResult({ onExit, }: PracticeResultProps) { const t = useT(); - const reviews = result.per_question.map((item) => ({ - ...item, - question: session.questions.find((q) => q.id === item.question_id), - })); + const bodyRef = useRef(null); + + const questions = useMemo( + () => + result.per_question.map((item, index) => ({ + item, + index, + question: session.questions.find((q) => q.id === item.question_id), + })), + [result.per_question, session.questions], + ); + const wrong = questions.filter(({ item }) => !item.correct); return ( - - -
- {t("result.review")} -
- - {reviews.map((review, index) => ( - - ) : ( - - ) - } - title={ - - {review.question && } - - {review.correct ? t("result.correct") : t("result.wrong")} - - - } - description={} + ({ + key: index, + label: index + 1, + status: item.correct ? "correct" : "wrong", + }))} + onSelect={(key) => scrollToQuestion(bodyRef.current, key)} + size="small" + /> + } + footer={ + + + + + } + > +
+ + {result.score} + /{result.total} + + {t("paper.score")} +
+ `${percent}%`} + status={result.accuracy === 100 ? "success" : "normal"} /> - ))} -
- - - - -
+ + + {t("result.correctN", { n: result.score })} + + + {t("result.wrongN", { n: wrong.length })} + + + {result.knowledge.length > 0 && ( +
+
{t("result.mastery")}
+ {result.knowledge.map((point) => ( + + {point.name} + + {point.mastery} + + + ))} +
+ )} + + } + main={ +
+
+
+
+
{result.title || session.title}
+
+ + {t("result.summary", { accuracy: result.accuracy })} + +
+
+
+
+
+ {questions.map(({ item, index, question }) => ( + + {question.knowledge_name} + + ) : null + } + stem={ + question ? ( + + ) : ( + + {t("question.legacy")} + + ) + } + footer={ + <> + + {item.explanation && ( +
+ +
+ )} + + } + > + {question && ( +
+ +
+ )} +
+ ))} +
+
+ } + /> ); } diff --git a/frontend/src/components/QuestionFlow.tsx b/frontend/src/components/QuestionFlow.tsx index 5119646..5d504b6 100644 --- a/frontend/src/components/QuestionFlow.tsx +++ b/frontend/src/components/QuestionFlow.tsx @@ -1,12 +1,23 @@ -import { useState } from "react"; -import { Button, Flex, Progress, Space, Tag, Typography } from "antd"; +/** + * 答题流程:整卷平铺 + 右侧答题卡。 + * + * 一屏只看一题、靠「下一题」翻页,做完后面就忘了前面, + * 这里按纸质卷的习惯改成整卷从上往下作答, + * 右侧答题卡实时显示已答 / 未答,点题号直接跳过去。 + * + * 章节没挂题目时组出来的是空卷:这里给出明确的空态并禁掉提交, + * 否则右侧会显示「还有 0 题未作答」,提交还会写一条 0 题的记录。 + */ +import { useMemo, useRef, useState } from "react"; +import { Button, Flex, Progress, Typography } from "antd"; import { api } from "../api/client"; import type { PracticeSession, SubmitResult } from "../types"; -import { Panel, useUi } from "./ui"; +import { Pane, SplitView } from "./SplitView"; +import { AnswerSheet, PaperQuestion, scrollToQuestion, useActiveAnchor } from "./Paper"; import { OptionPicker } from "./Options"; import { PracticeResult } from "./PracticeResult"; +import { EmptyState, useUi } from "./ui"; import RichText from "./RichText"; -import { tagColor } from "../theme"; import { useT } from "../prefs"; interface QuestionFlowProps { @@ -22,7 +33,7 @@ export default function QuestionFlow({ }: QuestionFlowProps) { const { message } = useUi(); const t = useT(); - const [index, setIndex] = useState(0); + const bodyRef = useRef(null); const [answers, setAnswers] = useState<(number | undefined)[]>( session.questions.map(() => undefined), ); @@ -30,8 +41,18 @@ export default function QuestionFlow({ const [result, setResult] = useState(null); const total = session.questions.length; - const question = session.questions[index]; - const progress = total ? Math.round((index / total) * 100) : 0; + const empty = total === 0; + const answered = answers.filter((value) => value !== undefined).length; + const progress = total ? Math.round((answered / total) * 100) : 0; + const keys = useMemo(() => session.questions.map((_, i) => i), [session.questions]); + const active = useActiveAnchor(bodyRef, keys, answered); + /** 当前滚动到的题(答题卡上高亮的那一道),用来在侧栏显示“正在做第几题”。 */ + const activeIndex = active === null ? 0 : Number(active); + const activeAnswer = answers[activeIndex]; + const activeAnswered = activeAnswer !== undefined; + const unansweredIndexes = answers + .map((value, index) => (value === undefined ? index : -1)) + .filter((index) => index >= 0); if (result) { return ( @@ -44,28 +65,33 @@ export default function QuestionFlow({ ); } - function choose(optionIndex: number) { + function choose(questionIndex: number, optionIndex: number) { setAnswers((prev) => { const next = prev.slice(); - next[index] = optionIndex; + next[questionIndex] = optionIndex; return next; }); } + function jumpTo(key: string | number) { + scrollToQuestion(bodyRef.current, key); + } + async function finish() { - if (answers.some((value) => value === undefined)) { - message.warning(t("flow.unfinished")); + if (empty) return; + const firstMissing = answers.findIndex((value) => value === undefined); + if (firstMissing >= 0) { + message.warning(t("flow.unfinishedAt", { index: firstMissing + 1 })); + jumpTo(firstMissing); return; } setSubmitting(true); try { - const payload = session.questions.map((q, i) => ({ - question_id: q.id, + const payload = session.questions.map((question, i) => ({ + question_id: question.id, selected: answers[i] as number, })); - setResult( - await api.submit(session.mode, payload, session.chapter_id), - ); + setResult(await api.submit(session.mode, payload, session.chapter_id)); } catch (err) { message.error(err instanceof Error ? err.message : t("flow.submitFailed")); } finally { @@ -73,45 +99,151 @@ export default function QuestionFlow({ } } - if (!question) return null; - - const isLast = index === total - 1; return ( - - - {session.title} - - {t("flow.progressPrefix")} {index + 1} / {total}{" "} - {t("flow.progressSuffix")} - - - -
- -
- - - - {!isLast ? ( - - ) : ( - - )} - -
+ + {t("flow.answered", { answered, total })} + + ) + } + toolbar={ + empty ? ( + + {t("flow.emptyPaperShort")} + + ) : ( + ({ + key: i, + label: i + 1, + status: answers[i] === undefined ? "pending" : "done", + hint: + answers[i] === undefined + ? t("flow.unanswered") + : t("flow.answeredAt", { + index: i + 1, + letter: String.fromCharCode(65 + (answers[i] as number)), + }), + }))} + activeKey={active} + onSelect={jumpTo} + size="small" + /> + ) + } + footer={ + + {!empty && ( + + )} + + {!empty && ( + + {progress === 100 + ? t("flow.readyToSubmit") + : t("flow.pendingN", { n: total - answered })} + + )} + + } + > + {!empty && ( + <> + {/* 当前题:滚动到哪一题、这题答了没有,不用去数题号 */} +
+ + {t("flow.currentNo", { index: activeIndex + 1 })} + + + {activeAnswered + ? t("flow.currentPicked", { + letter: String.fromCharCode(65 + (activeAnswer as number)), + }) + : t("flow.unanswered")} + +
+ {unansweredIndexes.length > 0 && unansweredIndexes.length < total && ( + + )} + + )} + + } + main={ +
+
+
{session.title}
+
+ {!empty && ( + + {t("flow.metaLine", { total, answered })} + + )} +
+
+
+ {empty && } + {session.questions.map((question, i) => ( + + {question.knowledge_name} + + } + stem={} + > +
+ choose(i, optionIndex)} + /> +
+
+ ))} +
+
+ } + /> ); } diff --git a/frontend/src/components/SplitView.tsx b/frontend/src/components/SplitView.tsx new file mode 100644 index 0000000..f95560a --- /dev/null +++ b/frontend/src/components/SplitView.tsx @@ -0,0 +1,236 @@ +/** + * 分屏工作台:试题库 / 测试记录 / 错题本 / 答题页共用同一套「清单 + 试卷」布局。 + * + * · 左右两栏各自独立滚动,清单不会把试卷顶走; + * · 中缝可拖拽调节宽度,偏好写入 localStorage; + * · 整块高度自动撑满视口剩余空间,页面本身不再长滚动; + * · 窄屏(<992px)自动退回上下堆叠,保证平板与笔记本可用。 + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; + +const DEFAULT_WIDTH = 320; +const MIN_WIDTH = 248; +const MAX_WIDTH = 560; +const WIDE_QUERY = "(min-width: 992px)"; + +function readStoredWidth(key?: string, fallback = DEFAULT_WIDTH): number { + if (!key) return fallback; + try { + const raw = localStorage.getItem(key); + const value = raw ? Number(raw) : Number.NaN; + return Number.isFinite(value) ? clamp(value) : fallback; + } catch { + return fallback; + } +} + +function clamp(value: number): number { + return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(value))); +} + +/** 是否宽屏。直接读 matchMedia,避免首帧先渲染堆叠版再跳版。 */ +function useIsWide(): boolean { + const [wide, setWide] = useState(() => + typeof window === "undefined" ? true : window.matchMedia(WIDE_QUERY).matches, + ); + useEffect(() => { + const mq = window.matchMedia(WIDE_QUERY); + const onChange = (event: MediaQueryListEvent) => setWide(event.matches); + setWide(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + return wide; +} + +/** + * 把容器高度收敛为「视口减去容器上方的距离」: + * 分屏内部自己滚动,外层页面不再出现第二条滚动条。 + */ +function useFillHeight(bottomGap: number) { + const ref = useRef(null); + const [height, setHeight] = useState(undefined); + + useEffect(() => { + const el = ref.current; + if (!el) return undefined; + let frame = 0; + + const measure = () => { + const top = el.getBoundingClientRect().top; + const next = Math.max(420, Math.round(window.innerHeight - top - bottomGap)); + setHeight((prev) => (prev !== undefined && Math.abs(prev - next) < 2 ? prev : next)); + }; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }; + + measure(); + // 页面标题区、工具条换行都会改变容器顶部位置,观察父级高度变化即可覆盖 + const observer = new ResizeObserver(schedule); + observer.observe(document.documentElement); + if (el.parentElement) observer.observe(el.parentElement); + window.addEventListener("resize", schedule); + window.addEventListener("scroll", schedule, { passive: true }); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", schedule); + window.removeEventListener("scroll", schedule); + }; + }, [bottomGap]); + + return { ref, height }; +} + +export interface SplitViewProps { + /** 侧栏:清单、章节树、答题卡都可以 */ + aside: ReactNode; + /** 主区:试卷 */ + main: ReactNode; + /** 侧栏所在的一侧;清单在左,成绩单/答题卡习惯放右 */ + side?: "left" | "right"; + defaultWidth?: number; + /** 传入后记住用户拖出来的宽度 */ + storageKey?: string; + /** 距离视口底部的留白 */ + bottomGap?: number; + className?: string; +} + +export function SplitView({ + aside, + main, + side = "left", + defaultWidth = DEFAULT_WIDTH, + storageKey, + bottomGap = 20, + className, +}: SplitViewProps) { + const wide = useIsWide(); + const [width, setWidth] = useState(() => readStoredWidth(storageKey, defaultWidth)); + const [dragging, setDragging] = useState(false); + const { ref, height } = useFillHeight(bottomGap); + + useEffect(() => { + if (!storageKey) return; + try { + localStorage.setItem(storageKey, String(width)); + } catch { + // 隐私模式下写失败就忘掉这个偏好 + } + }, [storageKey, width]); + + const startDrag = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = width; + const direction = side === "left" ? 1 : -1; + setDragging(true); + document.body.classList.add("split-dragging"); + + const onMove = (moveEvent: PointerEvent) => { + setWidth(clamp(startWidth + direction * (moveEvent.clientX - startX))); + }; + const onUp = () => { + setDragging(false); + document.body.classList.remove("split-dragging"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, + [side, width], + ); + + const resizer = ( +
+ ); + const asidePane = ( +
+ {aside} +
+ ); + const mainPane =
{main}
; + + if (!wide) { + return ( +
+ {asidePane} + {mainPane} +
+ ); + } + + return ( +
+ {side === "left" ? ( + <> + {asidePane} + {resizer} + {mainPane} + + ) : ( + <> + {mainPane} + {resizer} + {asidePane} + + )} +
+ ); +} + +export interface PaneProps { + title?: ReactNode; + extra?: ReactNode; + /** 标题下方的筛选条(搜索、筛选、答题卡等) */ + toolbar?: ReactNode; + footer?: ReactNode; + children: ReactNode; + /** 主区内容自己撑满高度(如试卷)时用 flat:正文不再叠内边距 */ + flat?: boolean; + className?: string; +} + +/** 分屏里的一栏:固定头部 + 独立滚动正文 + 可选吸底操作条。 */ +export function Pane({ + title, + extra, + toolbar, + footer, + children, + flat, + className, +}: PaneProps) { + return ( +
+ {(title || extra || toolbar) && ( +
+ {(title || extra) && ( +
+
{title}
+ {extra &&
{extra}
} +
+ )} + {toolbar &&
{toolbar}
} +
+ )} +
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 8d62185..473d45a 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -75,8 +75,20 @@ export function Panel({ title, extra, children, muted, className }: PanelProps) * 面板上方工具条:只放筛选控件与提示语。 * 主操作按钮一律放在功能页面 header 右侧,数量统计交给分页条,工具条不再承载按钮。 */ -export function Toolbar({ filters }: { filters?: ReactNode }) { - return
{filters}
; +export function Toolbar({ + filters, + extra, +}: { + filters?: ReactNode; + /** 挤到这一行最右侧(视图切换等) */ + extra?: ReactNode; +}) { + return ( +
+ {filters} + {extra &&
{extra}
} +
+ ); } /** 面板内的小节标题。 */ diff --git a/frontend/src/components/useApi.ts b/frontend/src/components/useApi.ts index 3effe99..ab5a21e 100644 --- a/frontend/src/components/useApi.ts +++ b/frontend/src/components/useApi.ts @@ -7,17 +7,29 @@ interface ApiState { reload: () => void; } +/** + * 取数钩子。`enabled: false` 时完全不发请求,也不置 loading, + * 让页面自己渲染引导空态——缺少前置条件(未选章节、未选教材)时 + * 不该把「还没开始」渲染成红色的「加载失败」。 + */ export function useApi( load: () => Promise, deps: unknown[] = [], + options?: { enabled?: boolean }, ): ApiState { const [data, setData] = useState(null); const [error, setError] = useState(null); const [version, setVersion] = useState(0); const loaderRef = useRef(load); loaderRef.current = load; + const enabled = options?.enabled ?? true; useEffect(() => { + if (!enabled) { + setData(null); + setError(null); + return; + } let alive = true; setData(null); setError(null); @@ -32,12 +44,12 @@ export function useApi( return () => { alive = false; }; - }, [version, ...deps]); + }, [version, enabled, ...deps]); return { data, error, - loading: data === null && error === null, + loading: enabled && data === null && error === null, reload: () => setVersion((v) => v + 1), }; } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f06531d..f450f02 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -295,9 +295,12 @@ const zh = { "daily.nextStep": "下一步", "daily.retest": "再做本章测试", "daily.openMap": "查看知识地图", - "chapterTest.selectChapterRequired": "请选择章节", + "chapterTest.emptyNoTextbook": "还没有教材,暂时无法按章节组卷。请管理员在「教材管理」中添加教材并整理章节目录。", + "chapterTest.emptyNoChapters": "{name} 还没有章节目录。请管理员在「教材管理」里添加章节,并在「题库管理」把题目挂到章节下,之后就能按章节组卷练习。", + "chapterTest.emptyPickChapter": "在上方选一章,系统会按你的薄弱知识点组一张卷子。", "chapterTest.title": "选择测试章节", "chapterTest.reshuffle": "换一套", + "chapterTest.countOption": "{n} 题", "chapterTest.textbookPlaceholder": "选择教材", "chapterTest.chapterPlaceholder": "选择章节", "chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成", @@ -323,8 +326,10 @@ const zh = { "knowledge.relPrereqOut": "后续知识点", "knowledge.relRelated": "相关知识点", "knowledge.masteryTitle": "知识点掌握度", + "knowledge.averageMastery": "平均掌握度", + "knowledge.practicedCount": "已练知识点", + "knowledge.focusCount": "待重点强化", "knowledge.bandAll": "全部", - "knowledge.emptyBand": "该档暂无知识点", "knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节", "knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度", "knowledge.detailTitle": "知识点详情", @@ -385,13 +390,27 @@ const zh = { "llm.willSave": "将保存为:{provider} · {model}", "graph.nodeTitle": "{name}(章 {chapters} · 题 {questions})", + "graph.domainAll": "全部领域", + "graph.searchPlaceholder": "搜索知识点", + "graph.canvasHint": "点知识点看关联;缩放/拖动可调整视图", + "graph.zoomIn": "放大", + "graph.zoomOut": "缩小", + "graph.zoomReset": "还原视图", + "graph.emptyDomain": "该领域还没有知识点", + "graph.notPracticed": "未练习", + "graph.masteryValue": "掌握度 {n}%", + "graph.nodeCounts": "题 {questions} · 章 {chapters}", + "graph.columnQuestions": "{n} 道题", + "graph.edgeLegend": "实线箭头 = 前置(先学箭头尾部的点),虚线 = 相关;点知识点高亮一跳关联", "graph.domainElementary": "初等数学", "graph.domainAdvanced": "高等数学", - "graph.domainPlaceholder": "选择知识领域", - "graph.allCategories": "全部(完整图谱)", "graph.add": "新增知识点", "graph.addRelation": "添加关系", "graph.detailTitle": "{name} · 详情", + "graph.detailTitleEmpty": "知识点详情", + "graph.detailEmpty": "在上方图谱或树里点一个知识点,这里显示它的定义、关系、关联章节与课程。", + "graph.viewGraph": "图谱", + "graph.viewTree": "树形", "graph.fieldDomain": "领域", "graph.fieldCategory": "分类", "graph.fieldUsage": "引用", @@ -433,10 +452,15 @@ const zh = { "graph.msgUnlinkFailed": "解除失败", "bank.colQuestion": "题目", - "bank.colSource": "来源 / 章节", - "bank.generated": "AI 生成", - "bank.builtin": "内置题目", + "bank.colChapter": "章节", + "bank.chapterTree": "教材 / 章节", + "bank.expandAll": "展开全部", + "bank.collapseAll": "收起全部", + "bank.expandBook": "展开章节", + "bank.collapseBook": "收起章节", + "bank.noChaptersYet": "暂无章节", "bank.noChapter": "未挂章节", + "bank.difficultyAll": "难度不限", "bank.colDifficulty": "难度", "bank.colUsage": "使用状态", "bank.usedN": "已使用 {n} 次", @@ -720,6 +744,72 @@ const zh = { "api.taskKindUnsupported": "不支持的任务类型:{kind}", "api.knowledgeSomeMissing": "部分知识点不存在", "api.ebookFileInvalid": "{name} 不是有效的 {format} 文件", + // —— 分屏试卷工作台(题库 / 记录 / 错题 / 答题共用)—— + "paper.prevPage": "上一页", + "paper.nextPage": "下一页", + "paper.pageRange": "第 {start}–{end} 题 / 共 {total} 题", + "paper.unanswered": "未作答", + "paper.yourAnswer": "我的作答:{answer}", + "paper.yourAnswerCorrect": "我的作答:{answer}(正确)", + "paper.correctAnswer": "正确答案:{answer}", + "paper.score": "得分", + "paper.filterAll": "全部 {n} 题", + "paper.filterWrong": "错题 {n} 题", + "paper.expandExplanations": "展开全部解析", + "paper.showExplanation": "查看解析", + "paper.emptyPaper": "这份试卷里还没有题目", + + "records.asideTitle": "练习记录", + "records.search": "搜索练习名称或日期", + "records.stat": "{sessions} 次 · 总正确率 {accuracy}%", + "records.summary": "{correct} / {total} 题正确", + "records.metaLine": "{date} · 共 {total} 题 · 答对 {correct} 题 · 正确率 {accuracy}%", + "records.pickOne": "从左侧选择一次练习,查看逐题明细", + "records.startTest": "去做一次章节测试", + "records.needsReview": "建议回看", + + "errors.asideTitle": "错题清单", + "errors.search": "搜索题干或知识点", + "errors.filterKnowledge": "按知识点筛选", + "errors.stat": "共 {n} 题", + "errors.detailTitle": "错题精讲", + "errors.metaLine": "来源:{source} · 记录于 {date} · 诊断:{diagnosis}", + "errors.position": "第 {index} / {total} 题", + "errors.prev": "上一题", + "errors.next": "下一题", + + "bank.asideTitle": "教材与章节", + "bank.navAll": "全部题目", + "bank.unattachedHint": "其中 {n} 题还没挂到章节", + "bank.search": "搜索题干、选项或知识点", + "bank.metaLine": "共 {total} 题 · 已使用 {used} 题", + "bank.noMatch": "当前筛选条件下没有题目,换个章节或清除筛选", + "bank.viewCard": "试卷视图", + "bank.viewTable": "表格视图", + "bank.usageAll": "使用不限", + "bank.filterUsed": "已使用", + "bank.filterUnused": "未使用", + "bank.difficultyValue": "{stars}", + + "flow.sheet": "答题卡", + "flow.answered": "已答 {answered} / {total}", + "flow.currentNo": "当前第 {index} 题", + "flow.currentPicked": "已选 {letter}", + "flow.jumpToUnanswered": "跳到第 {index} 题(未作答)", + "flow.answeredAt": "第 {index} 题已选 {letter}", + "flow.unanswered": "未作答", + "flow.answeredTag": "已作答", + "flow.metaLine": "共 {total} 题 · 已作答 {answered} 题", + "flow.unfinishedAt": "第 {index} 题还没有作答,请完成后提交", + "flow.pendingN": "还有 {n} 题未作答", + "flow.readyToSubmit": "全部题目已作答,可以提交了", + "flow.emptyPaper": "这一章还没有题目,暂时无法作答。请管理员在「题库管理」里添加题目并关联到本章。", + "flow.emptyPaperShort": "本章暂无题目,无法提交", + + "result.sheet": "成绩单", + "result.correctN": "答对 {n} 题", + "result.wrongN": "答错 {n} 题", + "result.mastery": "知识点掌握度", } as const; export type MsgKey = keyof typeof zh; @@ -1009,9 +1099,12 @@ const en: Record = { "daily.nextStep": "Next", "daily.retest": "Take this chapter's test again", "daily.openMap": "Open knowledge map", - "chapterTest.selectChapterRequired": "Please select a chapter", + "chapterTest.emptyNoTextbook": "No textbooks yet, so there is nothing to build a paper from. Ask an administrator to add a textbook and set up its chapters under Textbooks.", + "chapterTest.emptyNoChapters": "{name} has no chapter list yet. Ask an administrator to add chapters under Textbooks and link questions to them in Question Bank, then you can build a paper by chapter.", + "chapterTest.emptyPickChapter": "Pick a chapter above and we will build a paper around your weak knowledge points.", "chapterTest.title": "Choose a chapter to test", "chapterTest.reshuffle": "Reshuffle", + "chapterTest.countOption": "{n} questions", "chapterTest.textbookPlaceholder": "Select textbook", "chapterTest.chapterPlaceholder": "Select chapter", "chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank", @@ -1036,8 +1129,10 @@ const en: Record = { "knowledge.relPrereqOut": "Follow-up points", "knowledge.relRelated": "Related knowledge points", "knowledge.masteryTitle": "Mastery by knowledge point", + "knowledge.averageMastery": "Average mastery", + "knowledge.practicedCount": "Practiced points", + "knowledge.focusCount": "Needs focus", "knowledge.bandAll": "All", - "knowledge.emptyBand": "No knowledge points in this band", "knowledge.hint": "Click any knowledge point for its definition, links and textbook chapters", "knowledge.empty": "No mastery data yet — finish a chapter test and it will be tracked on the knowledge graph", "knowledge.detailTitle": "Knowledge point", @@ -1097,13 +1192,27 @@ const en: Record = { "llm.autoPlaceholder": "Auto if blank", "llm.willSave": "Will be saved as: {provider} · {model}", "graph.nodeTitle": "{name} (ch {chapters} · q {questions})", + "graph.domainAll": "All domains", + "graph.searchPlaceholder": "Search knowledge points", + "graph.canvasHint": "Click a point to see its links; zoom or drag to adjust the view", + "graph.zoomIn": "Zoom in", + "graph.zoomOut": "Zoom out", + "graph.zoomReset": "Reset view", + "graph.emptyDomain": "No knowledge points in this domain yet", + "graph.notPracticed": "Not practiced", + "graph.masteryValue": "Mastery {n}%", + "graph.nodeCounts": "{questions} questions · {chapters} chapters", + "graph.columnQuestions": "{n} questions", + "graph.edgeLegend": "Solid arrow = prerequisite (study the tail first); dashed = related. Click a point to highlight its direct links", "graph.domainElementary": "Elementary mathematics", "graph.domainAdvanced": "Advanced mathematics", - "graph.domainPlaceholder": "Select a domain", - "graph.allCategories": "All (full graph)", "graph.add": "New knowledge point", "graph.addRelation": "Add relation", "graph.detailTitle": "{name} · Details", + "graph.detailTitleEmpty": "Knowledge point", + "graph.detailEmpty": "Pick a knowledge point in the graph or tree above to see its definition, relations, chapters and courses here.", + "graph.viewGraph": "Graph", + "graph.viewTree": "Tree", "graph.fieldDomain": "Domain", "graph.fieldCategory": "Category", "graph.fieldUsage": "Used by", @@ -1144,10 +1253,15 @@ const en: Record = { "graph.msgUnlinked": "Link removed", "graph.msgUnlinkFailed": "Unlink failed", "bank.colQuestion": "Question", - "bank.colSource": "Source / Chapter", - "bank.generated": "AI-generated", - "bank.builtin": "Built-in", + "bank.colChapter": "Chapter", + "bank.chapterTree": "Textbooks / chapters", + "bank.expandAll": "Expand all", + "bank.collapseAll": "Collapse all", + "bank.expandBook": "Expand chapters", + "bank.collapseBook": "Collapse chapters", + "bank.noChaptersYet": "No chapters yet", "bank.noChapter": "No chapter", + "bank.difficultyAll": "Any level", "bank.colDifficulty": "Difficulty", "bank.colUsage": "Usage", "bank.usedN": "Used {n} times", @@ -1424,6 +1538,72 @@ const en: Record = { "api.taskKindUnsupported": "Unsupported task type: {kind}", "api.knowledgeSomeMissing": "Some knowledge points do not exist", "api.ebookFileInvalid": "{name} is not a valid {format} file", + // —— Split paper workbench (shared by bank / records / errors / test) —— + "paper.prevPage": "Previous page", + "paper.nextPage": "Next page", + "paper.pageRange": "{start}–{end} of {total} questions", + "paper.unanswered": "Not answered", + "paper.yourAnswer": "Your answer: {answer}", + "paper.yourAnswerCorrect": "Your answer: {answer} (correct)", + "paper.correctAnswer": "Correct answer: {answer}", + "paper.score": "Score", + "paper.filterAll": "All {n}", + "paper.filterWrong": "Wrong {n}", + "paper.expandExplanations": "Expand all explanations", + "paper.showExplanation": "Show explanation", + "paper.emptyPaper": "This paper has no questions yet", + + "records.asideTitle": "Practice log", + "records.search": "Search session or date", + "records.stat": "{sessions} sessions · {accuracy}% overall", + "records.summary": "{correct} / {total} correct", + "records.metaLine": "{date} · {total} questions · {correct} correct · {accuracy}% accuracy", + "records.pickOne": "Pick a session on the left to review it question by question", + "records.startTest": "Take a chapter test", + "records.needsReview": "Worth a review", + + "errors.asideTitle": "Mistake list", + "errors.search": "Search stem or knowledge point", + "errors.filterKnowledge": "Filter by knowledge", + "errors.stat": "{n} questions", + "errors.detailTitle": "Mistake walkthrough", + "errors.metaLine": "Source: {source} · logged {date} · diagnosis: {diagnosis}", + "errors.position": "Question {index} of {total}", + "errors.prev": "Previous", + "errors.next": "Next", + + "bank.asideTitle": "Textbooks & chapters", + "bank.navAll": "All questions", + "bank.unattachedHint": "{n} of them are not linked to a chapter yet", + "bank.search": "Search stem, options or knowledge point", + "bank.metaLine": "{total} questions · {used} used", + "bank.noMatch": "No question matches the current filters. Try another chapter or clear them", + "bank.viewCard": "Paper view", + "bank.viewTable": "Table view", + "bank.usageAll": "Any usage", + "bank.filterUsed": "Used", + "bank.filterUnused": "Unused", + "bank.difficultyValue": "{stars}", + + "flow.sheet": "Answer sheet", + "flow.answered": "{answered} / {total} answered", + "flow.currentNo": "Question {index}", + "flow.currentPicked": "Picked {letter}", + "flow.jumpToUnanswered": "Jump to question {index} (unanswered)", + "flow.answeredAt": "Question {index}: {letter}", + "flow.unanswered": "Unanswered", + "flow.answeredTag": "Answered", + "flow.metaLine": "{total} questions · {answered} answered", + "flow.unfinishedAt": "Question {index} is still unanswered. Finish it before submitting", + "flow.pendingN": "{n} question(s) left", + "flow.readyToSubmit": "All questions answered — ready to submit", + "flow.emptyPaper": "This chapter has no questions yet, so there is nothing to answer. Ask an administrator to add questions in Question Bank and link them to this chapter.", + "flow.emptyPaperShort": "No questions in this chapter yet", + + "result.sheet": "Report card", + "result.correctN": "{n} correct", + "result.wrongN": "{n} wrong", + "result.mastery": "Knowledge mastery", }; let currentLang: Lang = "zh"; diff --git a/frontend/src/pages/ChapterTest.tsx b/frontend/src/pages/ChapterTest.tsx index 22e5665..b1c1252 100644 --- a/frontend/src/pages/ChapterTest.tsx +++ b/frontend/src/pages/ChapterTest.tsx @@ -1,15 +1,13 @@ import { useEffect, useState } from "react"; -import { Button, Select, Space, Tag, Typography } from "antd"; -import { - BookOutlined, - InfoCircleOutlined, - ReloadOutlined, -} from "@ant-design/icons"; +import type { ReactNode } from "react"; +import { Button, Select, Tag, Typography } from "antd"; +import { BookOutlined, ReloadOutlined } from "@ant-design/icons"; import { api } from "../api/client"; import { AsyncState } from "../components/Async"; import QuestionFlow from "../components/QuestionFlow"; import { useApi } from "../components/useApi"; -import { Hint, KnowledgeTags, Page, Panel, Toolbar } from "../components/ui"; +import { PaperPlaceholder } from "../components/PaperReview"; +import { KnowledgeTags, Page } from "../components/ui"; import { useT } from "../prefs"; import { tagColor } from "../theme"; import type { ViewId } from "../nav"; @@ -21,6 +19,10 @@ interface ChapterTestProps { onReadOnline?: (textbookId: number, page?: number) => void; } +/** 默认题量与可选题量(后端 limit 上限为 10)。 */ +const DEFAULT_TEST_COUNT = 6; +const TEST_COUNT_OPTIONS = [4, 6, 8, 10]; + export default function ChapterTest({ initialChapterId, onNavigate, @@ -35,6 +37,8 @@ export default function ChapterTest({ initialChapterId ?? undefined, ); const [variant, setVariant] = useState(0); + /** 组卷题量:默认 6 题,用户可改(后端上限 10) */ + const [count, setCount] = useState(DEFAULT_TEST_COUNT); // 初始只有章节 id(从教材目录 / 今日任务跳转)时,先找到所属教材 useEffect(() => { @@ -63,12 +67,12 @@ export default function ChapterTest({ if (first) setChapterId(first.id); }, [currentBook, chapterId]); + // 组卷要先有章节:缺前置条件时干脆不发请求,交给下面的引导空态, + // 而不是 Promise.reject 把「还没选章节」显示成红色的「加载失败」。 const sessionState = useApi( - () => - chapterId - ? api.chapterTest(chapterId, variant, 5) - : Promise.reject(new Error(t("chapterTest.selectChapterRequired"))), - [chapterId, variant], + () => api.chapterTest(chapterId as number, variant, count), + [chapterId, variant, count], + { enabled: Boolean(chapterId) }, ); function chooseBook(id: number) { @@ -81,42 +85,37 @@ export default function ChapterTest({ setVariant((value) => value + 1); } + const note = [ + currentChapter?.summary, + t("chapterTest.logic"), + ] + .filter(Boolean) + .join(" · "); + + /** + * 组卷缺前置条件时的引导:没有教材 / 这本教材没章节 / 还没选章节。 + * 管理员菜单里没有本页,所以这里只对学生说话——告诉他缺什么、谁去补。 + */ + const emptyGuide: { description: ReactNode } | null = + chapterId || textbooksState.loading || textbooksState.error + ? null + : textbooks.length === 0 + ? { description: t("chapterTest.emptyNoTextbook") } + : chapters.length === 0 + ? { + description: t("chapterTest.emptyNoChapters", { + name: currentBook?.name ?? "", + }), + } + : { description: t("chapterTest.emptyPickChapter") }; + return ( - - {onReadOnline && currentBook?.ebook_format && ( - - )} - - - } - > - + {/* 组卷条件收在上方一条,下面整块留给答题工作台 */} +
+
{ @@ -138,37 +137,62 @@ export default function ChapterTest({ label: chapter.name, }))} /> - - } - /> + + } + placeholder={t("errors.search")} + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + style={{ flex: "1 1 140px", minWidth: 130 }} /> - ), - }} - /> - )} - + { - setDomain(value); - setCategoryId(0); - }} - style={{ width: 120 }} - options={[ - { value: "初等", label: t("graph.domainElementary") }, - { value: "高等", label: t("graph.domainAdvanced") }, - ]} - /> - setDomain(value)} + style={{ width: 150 }} + options={[ + { value: ALL_DOMAINS, label: t("graph.domainAll") }, + { value: "初等", label: t("graph.domainElementary") }, + { value: "高等", label: t("graph.domainAdvanced") }, + ]} + /> + } + extra={ + setViewMode(value as GraphViewMode)} + options={[ + { + value: "graph", + label: t("graph.viewGraph"), + icon: , + }, + { + value: "tree", + label: t("graph.viewTree"), + icon: , + }, + ]} + /> } /> - {data && ( -
+ {data && + (viewMode === "graph" ? ( + { + const full = data.nodes.find((item) => item.id === node.id); + setSelected(full ?? null); + }} + domainLabel={(value) => + value === "高等" + ? t("graph.domainAdvanced") + : t("graph.domainElementary") + } + /> + ) : ( +
{ - const id = Number(keys[0]); - const node = data?.nodes.find((item) => item.id === id); - if (node) setSelected(node); + const node = + data.nodes.find((item) => item.id === Number(keys[0])) ?? + null; + setSelected(node); }} />
- )} + ))} - {selected && ( - - } - > + ) : undefined + } + > + {!selected ? ( + {t("graph.detailEmpty")} + ) : ( + <>
- - )} + + )} + void; @@ -56,8 +67,10 @@ export default function KnowledgeMap({ }: KnowledgeMapProps) { const t = useT(); - const { data, loading, error, reload } = useApi(api.knowledge); + const { data, loading, error, reload } = useApi(api.knowledgeMap); const [band, setBand] = useState(initialBand ?? ALL_BANDS); + const [domain, setDomain] = useState(ALL_DOMAINS); + const [keyword, setKeyword] = useState(""); const [selectedId, setSelectedId] = useState(null); const detailState = useApi( () => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)), @@ -65,9 +78,50 @@ export default function KnowledgeMap({ ); const detail = detailState.data; - const knowledge = data ?? []; - const points = - band === ALL_BANDS ? knowledge : knowledge.filter((item) => item.status === band); + const nodes = data?.nodes ?? []; + const edges = data?.edges ?? []; + const visibleNodes = useMemo( + () => + nodes.filter((node) => domain === ALL_DOMAINS || node.domain === domain), + [nodes, domain], + ); + const visibleIds = useMemo( + () => new Set(visibleNodes.map((node) => node.id)), + [visibleNodes], + ); + const visibleEdges = useMemo( + () => + edges.filter( + (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target), + ), + [edges, visibleIds], + ); + + /** + * 档位与关键词只把不相关的点淡出,不改布局: + * 地图的骨架(先修链、分类列)要一直在,才看得出“我卡在哪一段”。 + */ + const dimmed = useMemo(() => { + const text = keyword.trim().toLowerCase(); + const set = new Set(); + visibleNodes.forEach((node) => { + const bandOk = band === ALL_BANDS || node.status === band; + const textOk = + !text || + node.name.toLowerCase().includes(text) || + node.category.toLowerCase().includes(text); + if (!bandOk || !textOk) set.add(node.id); + }); + return set; + }, [visibleNodes, band, keyword]); + + const practiced = nodes.filter((node) => node.practiced); + const average = practiced.length + ? Math.round( + practiced.reduce((sum, node) => sum + node.mastery, 0) / practiced.length, + ) + : 0; + const focusCount = practiced.filter((node) => node.status === "重点").length; const relatedGroups = (detail?.related ?? []).reduce< Record @@ -81,7 +135,7 @@ export default function KnowledgeMap({ {data && - (data.length > 0 ? ( + (nodes.length > 0 ? ( - {t("knowledge.hint")} + setDomain(String(value))} + options={[ + { value: ALL_DOMAINS, label: t("graph.domainAll") }, + { value: "初等", label: t("graph.domainElementary") }, + { value: "高等", label: t("graph.domainAdvanced") }, + ]} + /> + } + placeholder={t("graph.searchPlaceholder")} + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + style={{ width: 180 }} + /> } /> - {points.length === 0 ? ( - - ) : ( - - {points.map((item) => ( - - setSelectedId(item.knowledge_id)} - title={item.name} - extra={ - - {enumLabel(t, item.status)} - - } - > - - - - ))} - - )} + + + + 0 ? { content: { color: status.error } } : undefined + } + /> + + setSelectedId(node.id)} + dimmedIds={dimmed} + showMastery + domainLabel={(value) => + value === "高等" + ? t("graph.domainAdvanced") + : t("graph.domainElementary") + } + /> + {t("knowledge.hint")} ) : ( diff --git a/frontend/src/pages/QuestionBank.tsx b/frontend/src/pages/QuestionBank.tsx index 457ea51..27b5e49 100644 --- a/frontend/src/pages/QuestionBank.tsx +++ b/frontend/src/pages/QuestionBank.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { - Alert, Button, Checkbox, Form, Input, InputNumber, Progress, + Segmented, Select, Space, Tag, @@ -16,24 +16,28 @@ import { import type { ColumnsType } from "antd/es/table"; import { DeleteOutlined, + DownOutlined, EditOutlined, RobotOutlined, + RightOutlined, + SearchOutlined, } from "@ant-design/icons"; import { api } from "../api/client"; import { HeaderActions } from "../components/Layout"; import { AsyncState } from "../components/Async"; import { useApi } from "../components/useApi"; -import { QuestionDetail } from "../components/QuestionDetail"; +import { Pane, SplitView } from "../components/SplitView"; +import { PaperMetaText, PaperReview } from "../components/PaperReview"; +import type { ReviewItem } from "../components/PaperReview"; import { DataTable, EditorDrawer, EmptyState, + Hint, KnowledgeTags, - Panel, RowActions, SectionTitle, SubText, - Toolbar, rules, useUi, } from "../components/ui"; @@ -50,6 +54,9 @@ interface QuestionBankProps { preset?: { textbook_id?: number; chapter_id?: number } | null; } +/** 题目浏览的两档视图:试卷卡片(默认)与管理员批量维护用的表格。 */ +type BankView = "card" | "table"; + export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) { const t = useT(); @@ -62,6 +69,12 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) const [filterChapterId, setFilterChapterId] = useState( () => preset?.chapter_id, ); + const [keyword, setKeyword] = useState(""); + const [difficulty, setDifficulty] = useState(undefined); + const [usage, setUsage] = useState<"all" | "used" | "unused">("all"); + /** 左侧书籍-章节树:默认折叠,只自动展开当前定位到的那本书。 */ + const [openBooks, setOpenBooks] = useState>(() => new Set()); + const [viewMode, setViewMode] = useState("card"); const [genOpen, setGenOpen] = useState(false); const [genBusy, setGenBusy] = useState(false); const [genTextbookId, setGenTextbookId] = useState(); @@ -74,9 +87,6 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) const [genForm] = Form.useForm(); const [editForm] = Form.useForm(); - const filterChapters = - textbooks.find((item) => item.id === filterTextbookId)?.chapters ?? []; - const listState = useApi( () => api.bankQuestions({ @@ -85,6 +95,10 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) }), [filterTextbookId, filterChapterId], ); + // 侧栏的「全部题目」必须是全库真实题量:各章 question_count 之和会漏掉 + // 还没挂章节的题目,出现“全部题目 0 / 右栏共 18 题”这种自相矛盾。 + const totalsState = useApi(api.bankSummary); + const totals = totalsState.data; const llmState = useApi(api.llmSettings); const graphState = useApi(api.knowledgeGraph); @@ -95,6 +109,16 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) } }, [preset?.textbook_id, preset?.chapter_id]); + // 选中的书自动展开,其它书保持折叠,避免几十个章节一次性铺开 + useEffect(() => { + if (!filterTextbookId) return; + setOpenBooks((prev) => + prev.has(filterTextbookId) + ? prev + : new Set(prev).add(filterTextbookId), + ); + }, [filterTextbookId]); + useEffect(() => { if (!genOpen || !llmState.data) return; const defaultChannel = llmState.data.channels.find((channel) => channel.is_default); @@ -124,9 +148,33 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) setGenOpen(true); } - function handleFilterTextbookChange(textbookId?: number) { + function pickChapter(textbookId?: number, chapterId?: number) { setFilterTextbookId(textbookId); - setFilterChapterId(undefined); + setFilterChapterId(chapterId); + } + + function toggleBook(textbookId: number) { + setOpenBooks((prev) => { + const next = new Set(prev); + if (next.has(textbookId)) next.delete(textbookId); + else next.add(textbookId); + return next; + }); + } + + const booksWithChapters = textbooks.filter( + (book) => book.chapters.length > 0, + ); + const allBooksOpen = + booksWithChapters.length > 0 && + booksWithChapters.every((book) => openBooks.has(book.id)); + + function toggleAllBooks() { + setOpenBooks( + allBooksOpen + ? new Set() + : new Set(booksWithChapters.map((book) => book.id)), + ); } function handleGenTextbookChange(textbookId: number) { @@ -227,6 +275,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) if (next.status === "success") { message.success(backendText(next.message)); listState.reload(); + textbooksState.reload(); + totalsState.reload(); } else { message.error(backendText(next.message)); } @@ -241,6 +291,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) await api.deleteQuestion(item.id); message.success(t("common.msgDeleted")); listState.reload(); + textbooksState.reload(); + totalsState.reload(); } catch (err) { message.error(err instanceof Error ? err.message : t("common.msgDeleteFailed")); } @@ -296,6 +348,7 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) message.success(t("bank.msgUpdated", { id: updated.id })); setEditOpen(false); listState.reload(); + totalsState.reload(); } catch (err) { message.error(err instanceof Error ? err.message : t("common.msgSaveFailed")); } finally { @@ -303,7 +356,112 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) } } - const columns: ColumnsType = [ + const questions = useMemo(() => listState.data ?? [], [listState.data]); + + /** 侧栏筛选 + 关键词,全部在本地完成:题目量级不大,切换要即时。 */ + const filtered = useMemo(() => { + const text = keyword.trim().toLowerCase(); + return questions.filter((item) => { + if (difficulty && item.difficulty !== difficulty) return false; + if (usage === "used" && (item.used_count ?? 0) === 0) return false; + if (usage === "unused" && (item.used_count ?? 0) > 0) return false; + if (!text) return true; + return ( + item.stem.toLowerCase().includes(text) || + item.explanation.toLowerCase().includes(text) || + String(item.id) === text + ); + }); + }, [questions, keyword, difficulty, usage]); + + const summary = useMemo(() => { + const used = filtered.filter((item) => (item.used_count ?? 0) > 0).length; + return { total: filtered.length, used }; + }, [filtered]); + + const activeTextbook = textbooks.find((book) => book.id === filterTextbookId); + const activeChapter = activeTextbook?.chapters.find( + (chapter) => chapter.id === filterChapterId, + ); + const scopeTitle = activeChapter + ? `${activeTextbook?.name} · ${activeChapter.name}` + : activeTextbook + ? activeTextbook.name + : t("bank.navAll"); + + function questionActions(item: QuestionBankItem) { + const used = (item.used_count ?? 0) > 0; + return ( + , + onClick: () => openEdit(item), + }, + used + ? { + key: "delete", + label: t("common.delete"), + icon: , + danger: true, + disabled: true, + tooltip: t("bank.deleteBlocked"), + onClick: () => undefined, + } + : { + kind: "confirm" as const, + key: "delete", + label: t("common.delete"), + icon: , + danger: true, + title: t("bank.deleteTitle"), + description: , + onConfirm: () => remove(item), + }, + ]} + /> + ); + } + + function questionTags(item: QuestionBankItem) { + return ( + <> + + {t("bank.difficultyValue", { stars: "★".repeat(item.difficulty) })} + + {(item.used_count ?? 0) > 0 ? ( + + {t("bank.usedN", { n: item.used_count })} + + ) : ( + {t("bank.unused")} + )} + {!filterChapterId && ( + + {item.chapter_name || t("bank.noChapter")} + + )} + + ); + } + + const reviewItems: ReviewItem[] = filtered.map((item) => ({ + key: item.id, + stem: item.stem, + options: item.options, + correctIndex: item.correct_index, + explanation: item.explanation, + correct: null, + knowledgeNames: item.knowledge_names?.length + ? item.knowledge_names + : [item.knowledge_name], + tags: questionTags(item), + actions: questionActions(item), + })); + + const tableColumns: ColumnsType = [ { title: "ID", dataIndex: "id", width: 70 }, { title: t("bank.colQuestion"), @@ -316,9 +474,9 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) }, { title: t("analytics.knowledge"), - dataIndex: "knowledge_name", - width: 220, - render: (_value: string, item) => ( + key: "knowledge", + width: 200, + render: (_, item) => ( 0 @@ -329,30 +487,23 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) ), }, { - title: t("bank.colSource"), - key: "origin", - width: 120, + title: t("bank.colChapter"), + dataIndex: "chapter_name", + width: 160, render: (_, item) => ( - - - {item.is_generated ? t("bank.generated") : t("bank.builtin")} - - {item.chapter_name || t("bank.noChapter")} - + {item.chapter_name || t("bank.noChapter")} ), }, { title: t("bank.colDifficulty"), dataIndex: "difficulty", width: 90, - render: (value: number) => ( - {"★".repeat(value)} - ), + render: (value: number) => {"★".repeat(value)}, }, { title: t("bank.colUsage"), dataIndex: "used_count", - width: 130, + width: 120, render: (value: number) => (value ?? 0) > 0 ? ( {t("bank.usedN", { n: value })} @@ -363,160 +514,309 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) { title: t("common.actions"), key: "action", - width: 140, + width: 150, fixed: "right", - render: (_, item) => { - const used = (item.used_count ?? 0) > 0; - return ( - , - onClick: () => openEdit(item), - }, - used - ? { - key: "delete", - label: t("common.delete"), - icon: , - danger: true, - disabled: true, - tooltip: t("bank.deleteBlocked"), - onClick: () => undefined, - } - : { - kind: "confirm" as const, - key: "delete", - label: t("common.delete"), - icon: , - danger: true, - title: t("bank.deleteTitle"), - description: , - onConfirm: () => remove(item), - }, - ]} - /> - ); - }, + render: (_, item) => questionActions(item), }, ]; + const viewToggle = ( + setViewMode(value as BankView)} + options={[ + { value: "card", label: t("bank.viewCard") }, + { value: "table", label: t("bank.viewTable") }, + ]} + /> + ); + + const emptyHint = ( + } onClick={openGenerate}> + {t("bank.generate")} + + } + /> + ); + return ( <> - - - - - - ({ - value: chapter.id, - label: chapter.name, - }))} - /> - - } - /> - - {t("bank.hintPrefix")} - onNavigate("llm")}> - {t("bank.hintLink")} - - {t("bank.hintSuffix")} - - } - /> - - {activeTask && ( -
- {t("bank.taskTitle", { id: activeTask.id })} - - {backendText(activeTask.message)} -
- )} - - {listState.data && ( - - rowKey="id" - columns={columns} - scroll={{ x: 1240 }} - dataSource={listState.data} - locale={{ - emptyText: ( - - ), - }} - expandable={{ - expandedRowRender: (item) => ( - - ), - }} + + + + + {activeTask && ( +
+ {t("bank.taskTitle", { id: activeTask.id })} + - )} - + {backendText(activeTask.message)} +
+ )} + + + } + placeholder={t("bank.search")} + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + style={{ width: "100%" }} + /> + + + } + > + + +
+ {t("bank.chapterTree")} + {booksWithChapters.length > 0 && ( + + )} +
+ {textbooks.map((book) => { + const total = book.chapters.reduce( + (sum, chapter) => sum + chapter.question_count, + 0, + ); + const bookActive = filterTextbookId === book.id && !filterChapterId; + const hasChapters = book.chapters.length > 0; + const open = hasChapters && openBooks.has(book.id); + return ( +
+
+ + +
+ {open && ( +
+ {book.chapters.map((chapter) => { + const active = filterChapterId === chapter.id; + const empty = chapter.question_count === 0; + return ( + + ); + })} +
+ )} + {!hasChapters && ( +
+ {t("bank.noChaptersYet")} +
+ )} +
+ ); + })} +
+ + {t("bank.hintPrefix")} + onNavigate("llm")}> + {t("bank.hintLink")} + + {t("bank.hintSuffix")} + +
+ + } + main={ + viewMode === "card" ? ( + + + {t("bank.metaLine", { + total: summary.total, + used: summary.used, + })} + + {activeChapter?.summary && ( + <> +
+ {activeChapter.summary} + + )} + + } + tools={viewToggle} + items={reviewItems} + empty={emptyHint} + /> + ) : ( +
+
+
+
+
{scopeTitle}
+
+ + {t("bank.metaLine", { + total: summary.total, + used: summary.used, + })} + +
+
+
{viewToggle}
+
+
+
+ + {listState.data && ( + + rowKey="id" + columns={tableColumns} + scroll={{ x: 1180 }} + dataSource={filtered} + locale={{ emptyText: emptyHint }} + pagination={{ + pageSize: 20, + showSizeChanger: true, + showTotal: (total) => t("common.totalRows", { n: total }), + }} + /> + )} +
+
+ ) + } + /> - - - {item.correct ? t("records.correct") : t("records.incorrect")} - - - - - - ); +interface RecordsProps { + onNavigate?: (view: ViewId) => void; } -export default function Records() { +function accuracyOf(record: { correct: number; total: number }) { + return record.total ? Math.round((record.correct / record.total) * 100) : 0; +} + +export default function Records({ onNavigate }: RecordsProps) { const t = useT(); - const { data, loading, error, reload } = useApi(api.records); + const listState = useApi(api.records); + const records = useMemo(() => listState.data ?? [], [listState.data]); + const [keyword, setKeyword] = useState(""); + const [selectedId, setSelectedId] = useState(null); const [details, setDetails] = useState>({}); - const [loadingId, setLoadingId] = useState(null); + const [pendingId, setPendingId] = useState(null); const [detailErrors, setDetailErrors] = useState>({}); - async function loadDetail(record: RecordItem) { - if (details[record.id] || loadingId !== null) return; - setLoadingId(record.id); + const visible = useMemo(() => { + const text = keyword.trim().toLowerCase(); + if (!text) return records; + return records.filter( + (record) => + record.title.toLowerCase().includes(text) || + record.happened_on.toLowerCase().includes(text), + ); + }, [records, keyword]); + + // 默认选中最近一次训练,进页面就能看到卷子 + useEffect(() => { + if (selectedId === null && records.length > 0) { + setSelectedId(records[0].id); + } + }, [records, selectedId]); + + async function loadDetail(id: number) { + if (details[id] || pendingId === id) return; + setPendingId(id); setDetailErrors((prev) => { const next = { ...prev }; - delete next[record.id]; + delete next[id]; return next; }); try { - const detail = await api.recordDetail(record.id); - setDetails((prev) => ({ ...prev, [record.id]: detail })); - } catch (err) { - setDetailErrors((prev) => ({ - ...prev, - [record.id]: t("common.loadFailed"), - })); + const detail = await api.recordDetail(id); + setDetails((prev) => ({ ...prev, [id]: detail })); + } catch { + setDetailErrors((prev) => ({ ...prev, [id]: t("common.loadFailed") })); } finally { - setLoadingId(null); + setPendingId(null); } } - const columns: ColumnsType = [ - { title: t("records.colTime"), dataIndex: "happened_on", width: 130 }, - { title: t("records.colTraining"), dataIndex: "title" }, - { title: t("records.colTotal"), dataIndex: "total", width: 90, align: "center" }, - { title: t("records.colCorrect"), dataIndex: "correct", width: 90, align: "center" }, - { - title: t("records.colRate"), - key: "accuracy", - width: 240, - render: (_: unknown, record) => { - const percent = record.total - ? Math.round((record.correct / record.total) * 100) - : 0; - return ( - - - {record.total - record.correct > 0 ? ( - {t("records.wrongN", { n: record.total - record.correct })} - ) : ( - {t("records.allCorrect")} - )} - - ); - }, - }, - ]; + useEffect(() => { + if (selectedId !== null) void loadDetail(selectedId); + // 选中变化时补拉明细,其余依赖都是稳定引用 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const selected = selectedId === null ? null : (details[selectedId] ?? null); + const selectedError = selectedId === null ? null : detailErrors[selectedId]; + const loadingSelected = + selectedId !== null && !selected && !selectedError && pendingId === selectedId; + + const items: ReviewItem[] = (selected?.items ?? []).map((item) => ({ + key: `${item.question_id ?? "q"}-${item.question_index}`, + stem: item.stem, + options: item.options, + selected: item.selected, + correctIndex: item.correct_index, + explanation: item.explanation, + correct: item.correct, + knowledgeNames: item.knowledge_names?.length + ? item.knowledge_names + : [item.knowledge_name], + tags: + (item.correct === false) ? ( + + {t("records.needsReview")} + + ) : null, + })); + + const stats = useMemo(() => { + const totalQuestions = records.reduce((sum, record) => sum + record.total, 0); + const totalCorrect = records.reduce((sum, record) => sum + record.correct, 0); + return { + sessions: records.length, + questions: totalQuestions, + accuracy: totalQuestions + ? Math.round((totalCorrect / totalQuestions) * 100) + : 0, + }; + }, [records]); return ( - - {t("records.expandHint")}} /> - - {data && ( - - rowKey="id" - columns={columns} - dataSource={data} - locale={{ - emptyText: ( - - ), - }} - expandable={{ - onExpand: (expanded, record) => { - if (expanded) void loadDetail(record); - }, - expandedRowRender: (record) => { - if (loadingId === record.id) { - return ; - } - if (detailErrors[record.id]) { - return ( - void loadDetail(record)} - > - {t("common.retry")} - - } - /> - ); - } - const detail = details[record.id]; - if (!detail) { - return {t("records.detailPending")}; - } - return ( - - {detail.items.map((item) => ( - - ))} - - ); - }, - }} - /> - )} - + + {t("records.stat", { + sessions: stats.sessions, + accuracy: stats.accuracy, + })} + + } + toolbar={ + } + placeholder={t("records.search")} + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + /> + } + > + + {listState.data && visible.length === 0 && ( + + )} + {visible.map((record: RecordItem) => { + const percent = accuracyOf(record); + const active = record.id === selectedId; + return ( + + ); + })} + + } + main={ + selected ? ( + + {t("records.metaLine", { + date: selected.happened_on, + total: selected.total, + correct: selected.correct, + accuracy: accuracyOf(selected), + })} + + } + score={{ score: selected.correct, total: selected.total }} + items={items} + /> + ) : loadingSelected ? ( +
+
+ +
+
+ ) : selectedError ? ( +
+
+ void loadDetail(selectedId)}> + {t("common.retry")} + + ) + } + /> +
+
+ ) : ( + + + + ) : undefined + } + /> + ) + } + /> ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e02e6bd..86292f8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -17,6 +17,9 @@ --track: #f1f2f6; --track-raised: #ffffff; --brand-soft: rgba(91, 92, 226, 0.08); + --select-bg: var(--brand-soft); + --select-border: rgba(91, 92, 226, 0.45); + --select-glow: 0 0 0 3px rgba(91, 92, 226, 0.12); --success-soft: #f0fdf4; --success-soft-border: #bbf7d0; --danger-soft: #fef2f2; @@ -47,6 +50,9 @@ --track: #24263c; --track-raised: #3a3e5c; --brand-soft: rgba(91, 92, 226, 0.22); + --select-bg: var(--brand-soft); + --select-border: rgba(129, 130, 235, 0.55); + --select-glow: 0 0 0 3px rgba(129, 130, 235, 0.18); --success-soft: rgba(22, 163, 74, 0.14); --success-soft-border: rgba(22, 163, 74, 0.4); --danger-soft: rgba(220, 38, 38, 0.14); @@ -606,6 +612,13 @@ body { margin-bottom: 16px; } +.panel-toolbar-extra { + display: flex; + align-items: center; + gap: 8px; + margin-inline-start: auto; +} + .section-title { display: flex; @@ -735,6 +748,22 @@ body { opacity: 0.7; } +/* 选中项与全站同一套「浅底 + 品牌描边」;右侧的对勾保证一眼可辨 */ +.option-row--active, +.option-row--active:hover, +.option-row--active:focus { + background: var(--select-bg) !important; + border-color: var(--select-border) !important; + color: var(--text-strong) !important; + box-shadow: inset 0 0 0 1px var(--select-border); +} + +.option-row-check { + margin-inline-start: auto; + padding-inline-start: 10px; + color: var(--brand); +} + /* 答题题干:练习流程统一排版 */ .question-stem { font-size: 17px; @@ -779,6 +808,281 @@ body { min-width: min(520px, 100%); } +/* ---------------- 知识图谱画布(知识地图 / 图谱管理共用) ---------------- */ + +.kg-wrap { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: var(--surface); + overflow: hidden; +} + +.kg-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--surface-2); +} + +.kg-hint { + font-size: 12px; + color: var(--text-secondary); +} + +.kg-zoom { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.kg-zoom-value { + min-width: 42px; + text-align: center; + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.kg-canvas { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + background-image: radial-gradient(var(--border-soft) 1px, transparent 1px); + background-size: 22px 22px; + cursor: grab; + touch-action: none; +} + +.kg-canvas:active { + cursor: grabbing; +} + +.kg-content { + position: relative; + transform-origin: 0 0; +} + +.kg-edges { + position: absolute; + inset: 0; + pointer-events: none; +} + +.kg-edge { + fill: none; + stroke: var(--border-soft); + stroke-width: 1.4; + transition: opacity 0.18s ease, stroke 0.18s ease; +} + +.kg-edge--前置 { + stroke: rgba(91, 92, 226, 0.5); + stroke-width: 1.8; +} + +.kg-edge--相关 { + stroke-dasharray: 2 4; +} + +.kg-edge--包含 { + stroke-dasharray: 5 5; +} + +.kg-edge--focus { + stroke: var(--brand); + stroke-width: 2.6; +} + +.kg-edge--faded { + opacity: 0.18; +} + +.kg-edge--dim { + opacity: 0.12; +} + +.kg-arrow-head { + fill: rgba(91, 92, 226, 0.75); +} + +/* 域标题:一条横贯本域所有分类的细线 + 标题 */ +.kg-band { + position: absolute; + top: 14px; + height: 26px; + padding: 0 8px; + display: flex; + align-items: center; +} + +.kg-band::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + bottom: 0; + height: 1px; + background: var(--border-soft); +} + +.kg-band-label { + position: relative; + z-index: 1; + padding: 0 10px; + border-radius: 999px; + background: var(--brand-soft); + color: var(--brand); + font-size: 12px; + font-weight: 600; + line-height: 22px; +} + +.kg-column-head { + position: absolute; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--surface-2); + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease; +} + +.kg-column-head:hover { + border-color: var(--select-border); +} + +.kg-column-name { + font-size: 13px; + font-weight: 650; + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kg-column-meta { + flex: none; + font-size: 11.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.kg-node { + position: absolute; + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--border-soft); + border-radius: 9px; + background: var(--surface); + cursor: pointer; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + background 0.16s ease, + opacity 0.18s ease; +} + +.kg-node:hover { + border-color: var(--brand); +} + +.kg-node-dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--node-tone, var(--border-soft)); +} + +.kg-node--practiced { + border-color: var(--select-border); + border-color: color-mix(in srgb, var(--node-tone) 45%, var(--border-soft)); + background: var(--brand-soft); + background: color-mix(in srgb, var(--node-tone) 8%, var(--surface)); +} + +.kg-node--unpracticed .kg-node-dot { + background: transparent; + border: 1px dashed var(--text-muted); +} + +.kg-node-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--text-strong); +} + +.kg-node--unpracticed .kg-node-name { + color: var(--text-secondary); +} + +.kg-node-mastery { + flex: none; + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.kg-node--selected { + border-color: var(--select-border); + background: var(--select-bg); + box-shadow: var(--select-glow); +} + +.kg-node--faded { + opacity: 0.32; +} + +.kg-node--dim { + opacity: 0.18; +} + +.kg-legend { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 8px 12px; + border-top: 1px solid var(--border-soft); + background: var(--surface-2); + font-size: 12px; + color: var(--text-secondary); +} + +.kg-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.kg-legend-dot { + width: 9px; + height: 9px; + border-radius: 50%; +} + +.kg-legend-dot--empty { + background: transparent; + border: 1px dashed var(--text-muted); +} + .graph-detail { width: 380px; max-width: 100%; @@ -786,12 +1090,13 @@ body { top: 76px; } -.tree-box { - max-height: 620px; +/* 树视图:和画布同一块高度,长了在自己内部滚 */ +.graph-tree { + max-height: 560px; overflow: auto; - padding: 12px; + padding: 12px 14px; border: 1px solid var(--border-soft); - border-radius: 10px; + border-radius: 14px; background: var(--surface); } @@ -893,11 +1198,11 @@ body { color: inherit; } -/* 选中项:白底小块浮在面板上,底色与右侧内容一致 */ +/* 选中项:与其他地方的「选中」同一套语义(浅品牌底 + 品牌描边) */ .resource-tabs .ant-tabs-tab-active, .resource-tabs .ant-tabs-tab-active:hover { - background: var(--surface); - box-shadow: 0 1px 3px rgba(15, 17, 45, 0.14); + background: var(--select-bg); + box-shadow: inset 0 0 0 1px var(--select-border); } .resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn, @@ -1258,9 +1563,9 @@ body { } .daily-rate--active { - background: var(--brand-soft); - border-color: var(--brand); - box-shadow: inset 0 0 0 1px var(--brand); + background: var(--select-bg); + border-color: var(--select-border); + box-shadow: inset 0 0 0 1px var(--select-border); } .daily-rate-icon { @@ -1510,8 +1815,8 @@ body.split-dragging { } .master-item--active { - background: var(--brand-soft); - border-color: rgba(91, 92, 226, 0.35); + background: var(--select-bg); + border-color: var(--select-border); } .master-item-title { @@ -1640,8 +1945,9 @@ body.split-dragging { } .paper-question--active { - background: var(--surface-2); - box-shadow: inset 3px 0 0 var(--brand); + background: var(--select-bg); + /* 整圈细描边取代原来的左侧指示条:卡片感更强,也不偏不倚 */ + box-shadow: inset 0 0 0 1px var(--select-border); } .paper-question-rail { @@ -1765,8 +2071,8 @@ body.split-dragging { } .answer-chip--active { - border-color: var(--brand); - box-shadow: 0 0 0 2px var(--brand-soft); + border-color: var(--select-border); + box-shadow: var(--select-glow); } .answer-chip-icon { @@ -1786,7 +2092,8 @@ body.split-dragging { } .answer-chip--done { - border-color: rgba(91, 92, 226, 0.45); + background: var(--select-bg); + border-color: var(--select-border); color: var(--brand); } @@ -1795,59 +2102,62 @@ body.split-dragging { color: var(--text-muted); } -.answer-sheet-legend { +.answer-sheet-pager { display: flex; align-items: center; + gap: 2px; + font-size: 12px; + color: var(--text-secondary); +} + +.answer-sheet-pager-text { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* 答题侧栏的「当前题」卡片:滚动到哪一题、这题答没答 */ +.sheet-current { + display: flex; + align-items: center; + justify-content: space-between; gap: 10px; - font-size: 12px; - color: var(--text-secondary); + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--surface-2); } -.answer-sheet-legend-toggle { +.sheet-current-label { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} + +.sheet-current-state { + font-size: 12.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.sheet-current-state--done { + color: var(--brand); + font-weight: 600; +} + +.sheet-jump { + align-self: flex-start; + margin-top: 10px; border: 0; - background: transparent; padding: 0; - cursor: pointer; - color: var(--text-secondary); - font-size: 12px; - text-decoration: underline dotted; -} - -.answer-sheet-legend-items { - display: flex; -} - -.answer-sheet-legend-items span { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.answer-dot { - width: 9px; - height: 9px; - border-radius: 3px; - display: inline-block; -} - -.answer-dot--correct { - background: var(--success-soft); - border: 1px solid #16a34a; -} - -.answer-dot--wrong { - background: var(--danger-soft); - border: 1px solid #dc2626; -} - -.answer-dot--done { - background: var(--brand-soft); - border: 1px solid var(--brand); -} - -.answer-dot--pending { background: transparent; - border: 1px dashed var(--text-muted); + color: var(--brand); + font-size: 12.5px; + cursor: pointer; +} + +.sheet-jump:hover { + text-decoration: underline; } .answer-sheet-summary { @@ -1913,30 +2223,135 @@ body.split-dragging { } /* 清单里的可点分组(教材名)与更紧凑的子项(章节) */ -.master-group-button { +.master-tree-head { display: flex; align-items: center; - gap: 6px; - width: 100%; - padding: 5px 8px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--text-strong); - font: inherit; - text-align: start; - cursor: pointer; - transition: background 0.16s ease, color 0.16s ease; + justify-content: space-between; + gap: 8px; + margin: 4px 0 6px; + padding: 0 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); } -.master-group-button:hover { +.master-tree-toggle { + border: 0; + padding: 0; + background: transparent; + color: var(--brand); + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.master-tree-toggle:hover { + text-decoration: underline; +} + +/* 一本书:一行标题 + 折叠起来的章节列表 */ +.master-book { + margin-bottom: 2px; +} + +.master-book-row { + display: flex; + align-items: center; + gap: 2px; + border: 1px solid transparent; + border-radius: 8px; +} + +.master-book-row:hover { background: var(--surface-hover); } -.master-group-button--active { +.master-book-row--active { + background: var(--select-bg); + border-color: var(--select-border); +} + +.master-caret { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-inline-start: 4px; + border: 0; + border-radius: 6px; + padding: 0; + background: transparent; + color: var(--text-muted); + font-size: 10px; + cursor: pointer; +} + +.master-caret:hover:not(:disabled) { color: var(--brand); } +.master-caret:disabled { + cursor: default; +} + +.master-caret-empty { + color: var(--text-muted); +} + +.master-book-button { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 4px; + border: 0; + background: transparent; + color: var(--text-strong); + font: inherit; + font-weight: 600; + font-size: 13px; + text-align: start; + cursor: pointer; +} + +.master-book-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 章节缩进 + 左侧引导线,和书名的层级一眼分得清 */ +.master-chapters { + margin: 2px 0 6px 14px; + padding-inline-start: 8px; + border-inline-start: 1px solid var(--scrollbar); +} + +.master-chapter-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.master-item--zero .master-item-title, +.master-item--zero .chapter-nav-count { + color: var(--text-muted); + font-weight: 400; +} + +.master-book-empty { + margin: 0 0 4px 26px; + font-size: 12px; + color: var(--text-muted); +} + .master-item--tight { padding: 7px 10px; margin-bottom: 2px; @@ -2032,6 +2447,10 @@ body.split-dragging { max-width: 100%; } +.test-launcher-count { + width: 118px; +} + .test-launcher-meta { display: flex; align-items: center; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9a9be90..c0c7095 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -279,6 +279,15 @@ export interface QuestionBankItem { knowledge_ids: number[]; } +/** 题库侧栏计数:全库真实题量,与按章节累加的 question_count 不同。 */ +export interface QuestionBankSummary { + total: number; + unattached: number; + generated: number; + used: number; +} + + export interface QuestionUpdatePayload { stem: string; options: string[]; @@ -359,6 +368,25 @@ export interface KnowledgeGraphData { edges: KnowledgeGraphEdge[]; } +/** 学生视角的知识节点:结构 + 本人掌握度(未练过的也返回,practiced 为假)。 */ +export interface KnowledgeMapNode { + id: number; + name: string; + domain: string; + category: string; + description: string; + mastery: number; + status: string; + practiced: boolean; + chapter_count: number; + question_count: number; +} + +export interface KnowledgeMapData { + nodes: KnowledgeMapNode[]; + edges: KnowledgeGraphEdge[]; +} + export interface KnowledgeNodeCreatePayload { name: string; domain: string; diff --git a/scripts/init.sh b/scripts/init.sh index 30c8e09..c455201 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -1,33 +1,112 @@ #!/usr/bin/env bash -# 初始化:安装后端依赖并写入种子数据(data/math.db)。 +# 初始化本机运行环境:Python 虚拟环境 + 后端依赖 + 种子数据(+ 前端依赖)。 +# +# ./scripts/init.sh 准备后端与前端环境 +# ./scripts/init.sh --no-web 只准备后端 +# +# 可用环境变量: +# PIP_INDEX_URL 指定 pip/uv 的包源(默认走官方源,失败自动改用清华镜像) +# NPM_REGISTRY 指定 npm 包源(失败自动改用 npmmirror) set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" - mkdir -p data -if [[ ! -x backend/.venv/bin/python ]]; then - if command -v uv >/dev/null 2>&1; then - echo "[init] 使用 uv 创建 Python 虚拟环境…" - (cd backend && uv venv .venv --python 3.12) +WITH_WEB=1 +for arg in "$@"; do + case "$arg" in + --no-web) WITH_WEB=0 ;; + --web) WITH_WEB=1 ;; + -h|--help) sed -n '1,12p' "$0"; exit 0 ;; + *) echo "[init] 未知参数:$arg(可用:--no-web / --web)" >&2; exit 2 ;; + esac +done + +# ---------- 1. 定位 uv(PATH → ~/.local/bin → ~/.cargo/bin) ---------- +UV="" +for cand in uv "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do + if command -v "$cand" >/dev/null 2>&1; then + UV="$(command -v "$cand")" + break + fi +done + +# ---------- 2. 定位 Python 3.10+(系统 python3 常常太旧,优先带版本号的解释器) ---------- +py_ok() { "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; } + +pick_python() { + for p in python3.13 python3.12 python3.11 python3.10 python3; do + if command -v "$p" >/dev/null 2>&1 && py_ok "$p"; then command -v "$p"; return 0; fi + done + for p in /opt/homebrew/bin/python3.1* /usr/local/bin/python3.1*; do + if [ -x "$p" ] && py_ok "$p"; then echo "$p"; return 0; fi + done + return 1 +} + +# ---------- 3. 虚拟环境 ---------- +if [ -x backend/.venv/bin/python ]; then + echo "[init] 复用已有虚拟环境 backend/.venv($(backend/.venv/bin/python -V 2>&1))" +elif [ -n "$UV" ]; then + echo "[init] 使用 uv($UV)创建虚拟环境,优先 CPython 3.12…" + (cd backend && "$UV" venv .venv --python 3.12) 2>/dev/null || (cd backend && "$UV" venv .venv) +else + PY="$(pick_python || true)" + if [ -z "$PY" ]; then + echo "[init] 未找到 Python 3.10+(系统自带的 python3 版本过低)。推荐安装 uv 后重试:" >&2 + echo " curl -LsSf https://astral.sh/uv/install.sh | sh" >&2 + echo " 或 brew install python@3.12" >&2 + exit 1 + fi + echo "[init] 使用 $("$PY" -V 2>&1)($PY)创建虚拟环境…" + "$PY" -m venv backend/.venv +fi + +# ---------- 4. 后端依赖(默认源失败后自动改用国内镜像) ---------- +install_backend() { + if [ -n "$UV" ]; then + (cd backend && "$UV" pip install --python .venv/bin/python ${1:+--index-url "$1"} -r requirements.txt) else - if python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then - echo "[init] 使用 python3 创建虚拟环境…" - python3 -m venv backend/.venv - else - echo "[init] 未找到 Python 3.10+,请安装 uv(https://docs.astral.sh/uv/)后重试。" >&2 - exit 1 + (cd backend && .venv/bin/pip install ${1:+-i "$1"} -r requirements.txt) + fi +} + +if ! install_backend "${PIP_INDEX_URL:-}"; then + echo "[init] 默认包源安装失败,改用清华镜像重试…" >&2 + install_backend https://pypi.tuna.tsinghua.edu.cn/simple +fi + +# ---------- 5. 种子数据 ---------- +if [ -f data/math.db ]; then + echo "[init] data/math.db 已存在,跳过种子数据(重建:cd backend && .venv/bin/python -m seed --force)" +else + (cd backend && .venv/bin/python -m seed) +fi + +# ---------- 6. 前端依赖 ---------- +if [ "$WITH_WEB" -eq 1 ]; then + if ! command -v node >/dev/null 2>&1; then + echo "[init] 未找到 Node.js,前端跳过。请安装 Node 20.19+ 或 22.12+ 后执行:cd frontend && npm install" >&2 + else + if [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then + echo "[init] 当前 Node $(node -v) 过低,Vite 8 需要 Node 20.19+ / 22.12+,前端可能无法启动。" >&2 + fi + npm_install() { + if [ -n "${1:-}" ]; then (cd frontend && npm install --registry "$1"); else (cd frontend && npm install); fi + } + if [ -d frontend/node_modules ]; then + echo "[init] 前端依赖已存在,跳过 npm install" + elif ! npm_install "${NPM_REGISTRY:-}"; then + echo "[init] 默认 npm 源安装失败,改用 npmmirror 重试…" >&2 + npm_install https://registry.npmmirror.com fi fi fi -if command -v uv >/dev/null 2>&1; then - uv pip install --python backend/.venv/bin/python -r backend/requirements.txt -else - backend/.venv/bin/pip install -r backend/requirements.txt -fi - -(cd backend && .venv/bin/python -m seed) - -echo "[init] 完成。启动方式:./scripts/start.sh" +echo +echo "[init] 环境就绪:" +echo " Python $(backend/.venv/bin/python -V 2>&1) → backend/.venv" +echo " Node $(node -v 2>/dev/null || echo '未安装')" +echo " 数据库 $ROOT_DIR/data/math.db" +echo "[init] 启动:./scripts/start.sh → http://localhost:5173" diff --git a/scripts/start.sh b/scripts/start.sh index b2ad150..7a573ee 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # 同时启动后端(FastAPI :8000)与前端(Vite :5173)。 +# 端口可用 BACKEND_PORT / FRONTEND_PORT 覆盖。 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_PORT="${BACKEND_PORT:-8000}" +FRONTEND_PORT="${FRONTEND_PORT:-5173}" if [[ ! -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]]; then echo "尚未初始化,请先运行 ./scripts/init.sh" >&2 @@ -12,6 +15,24 @@ if [[ ! -f "$ROOT_DIR/data/math.db" ]]; then echo "数据库不存在,请先运行 ./scripts/init.sh" >&2 exit 1 fi +if ! command -v node >/dev/null 2>&1; then + echo "未找到 Node.js,前端无法启动。请安装 Node 20.19+ / 22.12+(或先只跑后端:cd backend && .venv/bin/uvicorn main:app --reload)" >&2 + exit 1 +fi + +# 端口占用早失败,避免 Vite 报一堆堆栈 +require_free_port() { + local port="$1" name="$2" + command -v lsof >/dev/null 2>&1 || return 0 + if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "端口 $port($name)已被占用:" >&2 + lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true + echo "请先停掉占用进程,或用 ${name}_PORT=其他端口 ./scripts/start.sh 换端口。" >&2 + exit 1 + fi +} +require_free_port "$BACKEND_PORT" BACKEND +require_free_port "$FRONTEND_PORT" FRONTEND if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then echo "[start] 安装前端依赖…" @@ -26,11 +47,11 @@ cleanup() { } trap cleanup EXIT INT TERM -echo "[start] 后端:http://localhost:${BACKEND_PORT:-8000}/api/health" +echo "[start] 后端:http://localhost:${BACKEND_PORT}/api/health" (cd "$ROOT_DIR/backend" && exec .venv/bin/uvicorn main:app \ - --host 0.0.0.0 --port "${BACKEND_PORT:-8000}" --reload) & + --host 0.0.0.0 --port "${BACKEND_PORT}" --reload) & BACKEND_PID=$! -echo "[start] 前端:http://localhost:${FRONTEND_PORT:-5173}" +echo "[start] 前端:http://localhost:${FRONTEND_PORT}" (cd "$ROOT_DIR/frontend" && exec npm run dev -- \ - --host 0.0.0.0 --port "${FRONTEND_PORT:-5173}") + --host 0.0.0.0 --port "${FRONTEND_PORT}")