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")}
+
+ }
+ onClick={() => zoom(-0.1)}
+ />
+ {Math.round(scale * 100)}%
+ }
+ onClick={() => zoom(0.1)}
+ />
+ }
+ onClick={() => {
+ setScale(1);
+ setOffset({ x: 0, y: 0 });
+ scrollerRef.current?.scrollTo({ left: 0, top: 0 });
+ }}
+ />
+
+
+
+
+
+
+ {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 && (
+
+ }
+ aria-label={t("paper.prevPage")}
+ disabled={current <= 0}
+ onClick={() => goTo(current - 1)}
+ />
+
+ {t("paper.pageRange", {
+ start: start + 1,
+ end: start + visible.length,
+ total: items.length,
+ })}
+
+ }
+ aria-label={t("paper.nextPage")}
+ disabled={current >= pageCount - 1}
+ onClick={() => goTo(current + 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={
+
+ }
+ onClick={onRestart}
+ >
+ {t("result.retry")}
+
+ } onClick={onExit}>
+ {t("result.back")}
+
+
+ }
+ >
+
+
+ {result.score}
+ /{result.total}
+
+ {t("paper.score")}
+
+
-
- } onClick={onRestart}>
- {t("result.retry")}
-
- } onClick={onExit}>
- {t("result.back")}
-
-
-
+
+
+ {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 && (
- }
- onClick={() =>
- onReadOnline(
- currentBook.id,
- currentChapter?.ebook_page || undefined,
- )
- }
- >
- {currentChapter?.ebook_page
- ? t("ebook.openAt", { page: currentChapter.ebook_page })
- : t("resources.readOnline")}
-
- )}
- }
- onClick={restart}
- disabled={!chapterId}
- >
- {t("chapterTest.reshuffle")}
-
-
- }
- >
-
+ {/* 组卷条件收在上方一条,下面整块留给答题工作台 */}
+
+
{currentChapter && (
-
-
- {currentChapter.summary || "—"}
-
-
+
+
{currentChapter.question_count === 0 && (
-
+
{t("chapterTest.noQuestions")}
)}
{currentChapter.question_count > 0 &&
currentChapter.question_count < 5 && (
-
- {t("chapterTest.fewQuestions", { n: currentChapter.question_count })}
+
+ {t("chapterTest.fewQuestions", {
+ n: currentChapter.question_count,
+ })}
)}
-
+
+
+ {note}
+
)}
-
-
-
- {t("chapterTest.logic")}
-
-
-
+
{sessionState.data ? (
onNavigate("dashboard")}
/>
+ ) : textbooksState.error ? (
+
+ ) : emptyGuide ? (
+
) : (
setVariant((value) => value + 1)}
+ onRetry={restart}
/>
)}
diff --git a/frontend/src/pages/Daily.tsx b/frontend/src/pages/Daily.tsx
index 466c371..2653556 100644
--- a/frontend/src/pages/Daily.tsx
+++ b/frontend/src/pages/Daily.tsx
@@ -134,7 +134,8 @@ export default function Daily({ onStartTest, onReadOnline, onNavigate }: DailyPr
setSubmitError(null);
try {
const result = await api.completeDaily(task.chapter_id, value);
- setSubmitted(result);
+ // 自评以刚提交的值为准:万一接口回读滞后,页面也不会显示成“已确认”或空自评
+ setSubmitted({ ...result, rating: result.rating || value });
setRating(value);
setDoneInfo({
from: isEdit ? result.streak : (task.streak ?? 0),
diff --git a/frontend/src/pages/Errors.tsx b/frontend/src/pages/Errors.tsx
index be1434a..a420de6 100644
--- a/frontend/src/pages/Errors.tsx
+++ b/frontend/src/pages/Errors.tsx
@@ -1,101 +1,271 @@
-import { Tag } from "antd";
-import type { ColumnsType } from "antd/es/table";
+/**
+ * 错题本:左侧错题清单,右侧单题精讲。
+ *
+ * 错题的用法是「一道一道啃」,不是一屏塞满表格,
+ * 因此左栏负责挑题(可按关键词与知识点筛),右栏按试卷版式给出
+ * 题干、你的作答、正确答案与解析,底部可以直接翻到下一道。
+ */
+import { useEffect, useMemo, useState } from "react";
+import { Button, Flex, Input, Select, Tag, Typography } from "antd";
+import {
+ ArrowLeftOutlined,
+ ArrowRightOutlined,
+ SearchOutlined,
+} from "@ant-design/icons";
import { api } from "../api/client";
import { AsyncState } from "../components/Async";
-import { QuestionDetail } from "../components/QuestionDetail";
+import { AnswerCompare, PaperQuestion } from "../components/Paper";
+import { OptionList, optionLetter } from "../components/Options";
+import { Pane, SplitView } from "../components/SplitView";
+import { PaperMetaText } from "../components/PaperReview";
import RichText from "../components/RichText";
import { useApi } from "../components/useApi";
-import {
- DataTable,
- EmptyState,
- KnowledgeTags,
- Panel,
- SubText,
-} from "../components/ui";
+import { EmptyState, KnowledgeTags } from "../components/ui";
import { backendText, enumLabel } from "../i18n";
import { useT } from "../prefs";
import { tagColor } from "../theme";
import type { ErrorEntry } from "../types";
+function knowledgeOf(entry: ErrorEntry): string[] {
+ const names = (entry.knowledge_names ?? []).length
+ ? (entry.knowledge_names as string[])
+ : [entry.knowledge_name];
+ return names.filter(Boolean);
+}
+
export default function Errors() {
const t = useT();
const { data, loading, error, reload } = useApi(api.errors);
+ const entries = useMemo(() => data ?? [], [data]);
+ const [keyword, setKeyword] = useState("");
+ const [knowledge, setKnowledge] = useState(undefined);
+ const [selectedId, setSelectedId] = useState(null);
- const columns: ColumnsType = [
- {
- title: t("errors.colRound"),
- dataIndex: "source_label",
- width: 170,
- render: (value: string) => {value},
- },
- {
- title: t("errors.colQuestion"),
- key: "stem",
- render: (_, entry) =>
- entry.stem ? (
-
- ) : (
- {t("errors.legacyNoDetail")}
- ),
- },
- {
- title: t("errors.colKnowledge"),
- key: "knowledge",
- render: (_, entry) => (
- 0
- ? entry.knowledge_names
- : [entry.knowledge_name]
- }
- />
- ),
- },
- {
- title: t("errors.colDiagnosis"),
- dataIndex: "diagnosis",
- width: 160,
- render: (value: string) => backendText(value),
- },
- {
- title: t("errors.colStatus"),
- dataIndex: "status",
- width: 100,
- render: (value: string) => (
- {enumLabel(t, value)}
- ),
- },
- { title: t("errors.colDate"), dataIndex: "created_on", width: 120 },
- ];
+ const knowledgeOptions = useMemo(() => {
+ const names = new Set();
+ entries.forEach((entry) => knowledgeOf(entry).forEach((name) => names.add(name)));
+ return Array.from(names)
+ .sort()
+ .map((name) => ({ value: name, label: name }));
+ }, [entries]);
+
+ const visible = useMemo(() => {
+ const text = keyword.trim().toLowerCase();
+ return entries.filter((entry) => {
+ if (knowledge && !knowledgeOf(entry).includes(knowledge)) return false;
+ if (!text) return true;
+ return (
+ (entry.stem ?? "").toLowerCase().includes(text) ||
+ entry.source_label.toLowerCase().includes(text)
+ );
+ });
+ }, [entries, keyword, knowledge]);
+
+ useEffect(() => {
+ if (visible.length === 0) {
+ if (selectedId !== null) setSelectedId(null);
+ return;
+ }
+ if (selectedId === null || !visible.some((entry) => entry.id === selectedId)) {
+ setSelectedId(visible[0].id);
+ }
+ }, [visible, selectedId]);
+
+ const index = visible.findIndex((entry) => entry.id === selectedId);
+ const current = index >= 0 ? visible[index] : null;
+
+ function step(offset: number) {
+ const next = visible[index + offset];
+ if (next) setSelectedId(next.id);
+ }
return (
-
-
- {data && (
-
- rowKey="id"
- columns={columns}
- dataSource={data}
- locale={{
- emptyText: (
-
- ),
- }}
- expandable={{
- expandedRowRender: (entry) => (
-
+ {t("errors.stat", { n: entries.length })}
+
+ }
+ toolbar={
+ <>
+ }
+ placeholder={t("errors.search")}
+ value={keyword}
+ onChange={(event) => setKeyword(event.target.value)}
+ style={{ flex: "1 1 140px", minWidth: 130 }}
/>
- ),
- }}
- />
- )}
-
+
+ >
+ }
+ >
+
+ {data && visible.length === 0 && (
+
+ )}
+ {visible.map((entry, position) => (
+
+ ))}
+
+ }
+ main={
+ current ? (
+
+
+
{t("errors.detailTitle")}
+
+
+ {t("errors.metaLine", {
+ source: current.source_label,
+ date: current.created_on,
+ diagnosis: backendText(current.diagnosis),
+ })}
+
+
+
+
+
+
+
+ {enumLabel(t, current.status)}
+
+ >
+ }
+ stem={
+ current.stem ? (
+
+ ) : (
+ t("errors.legacyHint")
+ )
+ }
+ footer={
+ current.stem ? (
+ <>
+
+ {current.explanation && (
+
+
+
+ )}
+ >
+ ) : null
+ }
+ >
+ {current.options && current.options.length > 0 && (
+
+
+
+ )}
+
+
+
+
+
+ {t("errors.position", { index: index + 1, total: visible.length })}
+
+
+ }
+ disabled={index <= 0}
+ onClick={() => step(-1)}
+ >
+ {t("errors.prev")}
+
+
+
+
+
+
+ ) : (
+
+
+ {!loading && !error && (
+
+
+
+ )}
+ {!loading && !error &&
}
+
+ )
+ }
+ />
);
}
+
+/** 空态时把页脚撑开,保持和详情态一样高。 */
+function PaperSpacer() {
+ return ;
+}
+
+/** 清单里只显示题干第一句,避免整道题挤满侧栏。 */
+function shorten(stem: string): string {
+ const plain = stem
+ .replace(/!\[[^\]]*\]\([^)\s]+[^)]*\)/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ return plain.length > 42 ? `${plain.slice(0, 42)}…` : plain;
+}
diff --git a/frontend/src/pages/KnowledgeGraph.tsx b/frontend/src/pages/KnowledgeGraph.tsx
index 5707187..e448db3 100644
--- a/frontend/src/pages/KnowledgeGraph.tsx
+++ b/frontend/src/pages/KnowledgeGraph.tsx
@@ -5,6 +5,7 @@ import {
Flex,
Form,
Input,
+ Segmented,
Select,
Space,
Tag,
@@ -12,8 +13,10 @@ import {
} from "antd";
import type { TreeDataNode } from "antd";
import {
+ ApartmentOutlined,
DeleteOutlined,
EditOutlined,
+ PartitionOutlined,
PlusOutlined,
ShareAltOutlined,
} from "@ant-design/icons";
@@ -21,6 +24,7 @@ import { api } from "../api/client";
import { HeaderActions } from "../components/Layout";
import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi";
+import KnowledgeGraphView from "../components/KnowledgeGraphView";
import {
EditorDrawer,
FormRow,
@@ -41,6 +45,10 @@ import type {
KnowledgeGraphNode,
} from "../types";
+/** 领域筛选取「全部」时的取值,取值本身不参与提交。 */
+const ALL_DOMAINS = "all";
+
+/** 树视图:按「包含」关系折叠成层级,和画布看的是同一份数据。 */
function buildTreeData(
nodes: KnowledgeGraphNode[],
edges: KnowledgeGraphEdge[],
@@ -65,10 +73,10 @@ function buildTreeData(
return {
key: String(id),
title: tr("graph.nodeTitle", {
- name: node.name,
- chapters: node.chapter_count,
- questions: node.question_count,
- }),
+ name: node.name,
+ chapters: node.chapter_count,
+ questions: node.question_count,
+ }),
children: kids.length ? kids : undefined,
};
}
@@ -76,6 +84,9 @@ function buildTreeData(
return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item));
}
+/** 图谱 / 树两种呈现方式,切换器放在筛选行的右端。 */
+type GraphViewMode = "graph" | "tree";
+
export default function KnowledgeGraph() {
const t = useT();
@@ -83,7 +94,7 @@ export default function KnowledgeGraph() {
const { data, loading, error, reload } = useApi(api.knowledgeGraph);
const coursesState = useApi(api.courses);
const [domain, setDomain] = useState("初等");
- const [categoryId, setCategoryId] = useState(0);
+ const [viewMode, setViewMode] = useState("graph");
const [selected, setSelected] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState(null);
@@ -102,79 +113,44 @@ export default function KnowledgeGraph() {
}, [data, selected]);
const nodes = useMemo(
- () => (data?.nodes ?? []).filter((node) => node.domain === domain),
+ () =>
+ (data?.nodes ?? []).filter(
+ (node) => domain === ALL_DOMAINS || node.domain === domain,
+ ),
[data, domain],
);
- const domainRoot = nodes.find(
- (node) => node.name === (domain === "初等" ? "初等数学" : "高等数学"),
+ const visibleIds = useMemo(
+ () => new Set(nodes.map((node) => node.id)),
+ [nodes],
);
- const categoryRoots = useMemo(
+ const visibleEdges = useMemo(
() =>
- (data?.edges ?? [])
- .filter(
- (edge) =>
- edge.relation_type === "包含" &&
- edge.source === domainRoot?.id,
- )
- .map((edge) => nodes.find((node) => node.id === edge.target))
- .filter((node): node is KnowledgeGraphNode => Boolean(node)),
- [data, nodes, domainRoot],
+ (data?.edges ?? []).filter(
+ (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
+ ),
+ [data, visibleIds],
);
-
- const subNodes = useMemo(
- () => {
- if (categoryId === 0) return nodes;
- const children = new Map();
- (data?.edges ?? []).forEach((edge) => {
- if (edge.relation_type === "包含") {
- const list = children.get(edge.source) ?? [];
- list.push(edge.target);
- children.set(edge.source, list);
- }
- });
- const ids = new Set([categoryId]);
- const queue = [categoryId];
- while (queue.length) {
- const current = queue.shift()!;
- (children.get(current) ?? []).forEach((id) => {
- if (!ids.has(id)) {
- ids.add(id);
- queue.push(id);
- }
- });
- }
- return nodes.filter((node) => ids.has(node.id));
- },
- [categoryId, data, nodes],
- );
- const subNodeIds = new Set(subNodes.map((node) => node.id));
- const roots = subNodes
- .filter(
- (node) =>
- !(data?.edges ?? []).some(
- (edge) =>
- edge.relation_type === "包含" &&
- edge.target === node.id &&
- subNodeIds.has(edge.source),
- ),
- )
- .map((node) => node.id);
-
- const treeData = useMemo(
- () => buildTreeData(subNodes, data?.edges ?? [], roots),
- [subNodes, data, roots],
- );
- const expandedRootKeys = roots.map(String);
+ /** 树视图的根:在本域内没有被「包含」指向的节点(通常就是域根)。 */
+ const treeData = useMemo(() => {
+ const roots = nodes
+ .filter(
+ (node) =>
+ !visibleEdges.some(
+ (edge) =>
+ edge.relation_type === "包含" && edge.target === node.id,
+ ),
+ )
+ .map((node) => node.id);
+ return buildTreeData(nodes, visibleEdges, roots);
+ }, [nodes, visibleEdges]);
const nodeById = new Map(nodes.map((node) => [node.id, node]));
function openCreate() {
setEditing(null);
form.resetFields();
- const defaultCategory =
- categoryRoots.find((node) => node.id === categoryId)?.name ?? "";
form.setFieldsValue({
- domain,
- category: defaultCategory,
+ domain: domain === ALL_DOMAINS ? "初等" : domain,
+ category: selected?.category ?? "",
description: "",
});
setEditOpen(true);
@@ -329,62 +305,84 @@ export default function KnowledgeGraph() {
- {
- setDomain(value);
- setCategoryId(0);
- }}
- style={{ width: 120 }}
- options={[
- { value: "初等", label: t("graph.domainElementary") },
- { value: "高等", label: t("graph.domainAdvanced") },
- ]}
- />
- {
- setCategoryId(value);
- setSelected(null);
- }}
- options={[
- { value: 0, label: t("graph.allCategories") },
- ...categoryRoots.map((node) => ({
- value: node.id,
- label: node.name,
- })),
- ]}
- />
- >
+ 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 (
<>
-
-
- }
- disabled={textbooks.length === 0}
- onClick={openGenerate}
- >
- {t("bank.generate")}
-
-
-
- ({
- value: book.id,
- label: book.name,
- }))}
- />
- ({
- 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) => (
-
- ),
- }}
+
+ }
+ disabled={textbooks.length === 0}
+ onClick={openGenerate}
+ >
+ {t("bank.generate")}
+
+
+
+ {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}")