main
mula.liu 2026-09-19 17:26:28 +08:00
parent b3dc8526f6
commit f61a0bb913
36 changed files with 4242 additions and 948 deletions

View File

@ -1,3 +1,9 @@
# backend/database.py 在导入时读取本文件:仓库根 .env 优先,backend/.env 补缺,
# 已存在的进程环境变量不会被覆盖(环境变量 > .env > 代码默认值)。
#
# 本机已经有一份可直接用的 .env(含绝对路径的 DATABASE_URL);
# 这个文件是换机器/重新部署时的模板。
# 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db # 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db
# 例:sqlite:////Users/you/nex_math/data/math.db # 例:sqlite:////Users/you/nex_math/data/math.db
DATABASE_URL= DATABASE_URL=
@ -5,8 +11,8 @@ DATABASE_URL=
# 允许跨域访问的前端地址,逗号分隔 # 允许跨域访问的前端地址,逗号分隔
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置) # 可选:仅在初始化(python -m seed)建默认模型通道时使用
OPENAI_API_KEY= OPENAI_API_KEY=
# JWT 签名密钥(生产环境务必改成随机长字符串) # JWT 签名密钥(生产环境务必改成随机长字符串;改动后所有人需重新登录)
JWT_SECRET= JWT_SECRET=

View File

@ -182,9 +182,11 @@ docker compose up --build
**有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS` **有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed` 是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` / 会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。 `DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。 目前已落地:函数和图像 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`:允许访问的前端地址 - `CORS_ORIGINS`:允许访问的前端地址
- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建 - `OPENAI_API_KEY`:可选。只在初始化(`python -m seed`)时用来创建
“OpenAI · gpt-4o-mini”默认通道并使用该 Key; “OpenAI · gpt-4o-mini”默认通道;正常使用的模型通道请在「模型配置」里维护,
`ai_teacher.py` 的逐题讲解仍为本地规则实现 `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`(整库)→ 重启后端。 或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。
* 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。 * 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。 * 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书 若库丢了,初始化时的 `seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的
(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。 已知电子书(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
(这一步只在 `python -m seed` 时执行,后端启动不再改动库与电子书文件。)

View File

@ -1,7 +1,13 @@
"""SQLite 数据库连接与会话管理。""" """SQLite 数据库连接与会话管理。
配置来源(优先级从高到低):进程环境变量 → 仓库根 ``.env`` →
``backend/.env`` → 代码里的默认值。``.env`` 在本模块导入时即被读入,
因此 ``DATABASE_URL`` / ``CORS_ORIGINS`` / ``JWT_SECRET`` 等都在此生效。
"""
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
@ -11,6 +17,31 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB_PATH = REPO_ROOT / "data" / "math.db" 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): class Base(DeclarativeBase):
@ -41,7 +72,12 @@ if DATABASE_URL.startswith("sqlite"):
def ensure_schema() -> None: def ensure_schema() -> None:
"""建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。""" """建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。
只做**表与字段**层面的对齐,不写任何业务数据:后端每次启动都可以安全
重放。种子数据与历史数据迁移只在初始化时执行 ——
``cd backend && .venv/bin/python -m seed``。
"""
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
inspector = inspect(engine) inspector = inspect(engine)
@ -114,14 +150,13 @@ def ensure_schema() -> None:
) )
) )
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
_split_courses()
def migrate_legacy_courses() -> None:
def _split_courses() -> None:
"""把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。 """把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。
教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。 教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。
属于数据迁移,只在 ``python -m seed`` 初始化时执行;调用前需先跑
``ensure_schema()``(要用到新补的 ``knowledge_resources.course_id``)。
幂等:kind 列不存在(已迁移过)时直接返回。 幂等:kind 列不存在(已迁移过)时直接返回。
""" """
inspector = inspect(engine) inspector = inspect(engine)

View File

@ -1,15 +1,13 @@
"""数学学习系统后端入口。""" """数学学习系统后端入口。"""
from __future__ import annotations from __future__ import annotations
import json
import os import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles 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 ( from routers import (
analytics, analytics,
archive, archive,
@ -25,71 +23,13 @@ from routers import (
question_bank, question_bank,
users, 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() 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]: def _cors_origins() -> list[str]:

View File

@ -2,6 +2,7 @@ fastapi>=0.115,<1.0
uvicorn[standard]>=0.30 uvicorn[standard]>=0.30
sqlalchemy>=2.0,<3.0 sqlalchemy>=2.0,<3.0
pydantic>=2.7,<3.0 pydantic>=2.7,<3.0
python-dotenv>=1.0,<2.0
httpx>=0.27,<1.0 httpx>=0.27,<1.0
pyjwt>=2.8,<3.0 pyjwt>=2.8,<3.0
python-multipart>=0.0.20 python-multipart>=0.0.20

View File

@ -7,7 +7,7 @@ from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import SessionLocal, get_db
from dependencies import require_learning_user from dependencies import require_learning_user
from models import ( from models import (
AttemptItem, 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: if completion and completion.chapter_id is not None:
chapter = db.get(Chapter, completion.chapter_id) chapter = db.get(Chapter, completion.chapter_id)
textbook = ( if chapter is not None:
db.get(Textbook, chapter.textbook_id) if chapter is not None else None return _completed_context(db, chapter, completion.rating)
)
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),
}
recommendation = build_daily_recommendation(db, user_id) or {} recommendation = build_daily_recommendation(db, user_id) or {}
return { return {
"chapter_id": recommendation.get("chapter_id"), "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: def _streak(done_dates: set[date]) -> int:
today = date.today() today = date.today()
if today not in done_dates and today - timedelta(days=1) not in done_dates: 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.chapter_id = chapter.id
exists.rating = payload.rating exists.rating = payload.rating
db.commit() db.commit()
done_dates = { # 写完用新会话回读连续天数:同一个 session 复用连接时可能读到写入前的快照,
row.done_on # 会让响应里的连续天数与自评对不上(前端就会显示成提交没生效)。
for row in db.query(DailyCompletion) read_db = SessionLocal()
.filter(DailyCompletion.user_id == user.id) try:
.all() 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( return DailyOut(
today=today, today=today,
completed=True, completed=True,
streak=_streak(done_dates), streak=_streak(done_dates),
**_daily_context(db, user.id, today), **_completed_context(db, chapter, payload.rating),
) )

View File

@ -6,7 +6,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from dependencies import require_permission from dependencies import require_learning_user, require_permission
from models import ( from models import (
ChapterKnowledge, ChapterKnowledge,
Course, Course,
@ -22,12 +22,15 @@ from schemas import (
KnowledgeGraphEdge, KnowledgeGraphEdge,
KnowledgeGraphNode, KnowledgeGraphNode,
KnowledgeGraphOut, KnowledgeGraphOut,
KnowledgeMapNode,
KnowledgeMapOut,
KnowledgeNodeCreate, KnowledgeNodeCreate,
KnowledgeNodeUpdate, KnowledgeNodeUpdate,
KnowledgeRelationIn, KnowledgeRelationIn,
KnowledgeResourceIn, KnowledgeResourceIn,
KnowledgeResourceOut, KnowledgeResourceOut,
) )
from services.mastery_engine import status_for
from services.knowledge_service import knowledge_resources 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) @router.post("/nodes", response_model=KnowledgeGraphNode)
def create_node( def create_node(
payload: KnowledgeNodeCreate, payload: KnowledgeNodeCreate,

View File

@ -5,6 +5,7 @@ import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
@ -19,7 +20,12 @@ from models import (
Textbook, Textbook,
User, User,
) )
from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate from schemas import (
BankSummaryOut,
GenerateIn,
QuestionBankItemOut,
QuestionUpdate,
)
from services.question_generator import generate_questions from services.question_generator import generate_questions
from services.llm_config import get_default_channel from services.llm_config import get_default_channel
from services.knowledge_service import ( 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] 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}") @router.delete("/questions/{question_id}")
def delete_question( def delete_question(
question_id: int, question_id: int,

View File

@ -303,6 +303,14 @@ class QuestionBankItemOut(BaseModel):
knowledge_ids: list[int] = [] knowledge_ids: list[int] = []
class BankSummaryOut(BaseModel):
"侧栏计数用:全库题目总数与几个切片,避免把整库题目拉回前端数数。"
total: int
unattached: int
generated: int
used: int
class QuestionUpdate(BaseModel): class QuestionUpdate(BaseModel):
stem: str = Field(min_length=1) stem: str = Field(min_length=1)
options: list[str] = Field(min_length=4, max_length=4) options: list[str] = Field(min_length=4, max_length=4)
@ -541,6 +549,26 @@ class KnowledgeGraphOut(BaseModel):
edges: list[KnowledgeGraphEdge] = [] 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): class KnowledgeNodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64)
domain: str = "初等" domain: str = "初等"

View File

@ -2,6 +2,10 @@
用法:在 backend/ 目录下执行 python -m seed [--force] 用法:在 backend/ 目录下执行 python -m seed [--force]
--force 会清空全部学习数据与题库后重新导入,保留用户与角色。 --force 会清空全部学习数据与题库后重新导入,保留用户与角色。
这里是**唯一**会补种数据的地方:后端每次启动只对齐表结构
(``database.ensure_schema``),不再补章节 / 知识点 / 教材等业务数据,
因此自己准备的 math.db 覆盖回来后不会被种子改动。
""" """
from __future__ import annotations from __future__ import annotations
@ -9,7 +13,14 @@ import argparse
import json import json
from datetime import date, timedelta 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 ( from models import (
AttemptItem, AttemptItem,
AttemptSession, AttemptSession,
@ -22,7 +33,9 @@ from models import (
from seed.catalog import ensure_catalog from seed.catalog import ensure_catalog
from seed.data import KNOWLEDGE, QUESTIONS from seed.data import KNOWLEDGE, QUESTIONS
from seed.ebooks import ensure_ebooks from seed.ebooks import ensure_ebooks
from seed.knowledge_graph import ensure_knowledge_graph
from seed.rbac import DEFAULT_USERS, ensure_rbac from seed.rbac import DEFAULT_USERS, ensure_rbac
from services import ebooks as ebook_store
from services.learning_profile import ( from services.learning_profile import (
ensure_default_profiles, ensure_default_profiles,
remove_unused_admin_profile, 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))) 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: def load_seed(force: bool = False) -> bool:
ensure_schema() ensure_schema()
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
migrate_legacy_courses()
db = SessionLocal() db = SessionLocal()
created = False created = False
try: try:
ensure_rbac(db) ensure_rbac(db)
ensure_knowledge_graph(db)
users = { users = {
username: db.query(User).filter(User.username == username).first() username: db.query(User).filter(User.username == username).first()
for username, _, _, _ in DEFAULT_USERS for username, _, _, _ in DEFAULT_USERS
@ -164,6 +223,8 @@ def load_seed(force: bool = False) -> bool:
ensure_catalog(db, force_sync=force) ensure_catalog(db, force_sync=force)
# 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好 # 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好
ensure_ebooks(db) ensure_ebooks(db)
ebook_store.backfill_pages(db)
_backfill_derived_fields(db)
for user in users.values(): for user in users.values():
if user and user_has_admin_role(db, user.id): if user and user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id) remove_unused_admin_profile(db, user.id)

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>数学学习系统</title> <title>数学学习系统</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b5ce2" />
<stop offset="1" stop-color="#20224a" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)" />
<text
x="32"
y="46"
text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="38"
font-weight="700"
fill="#ffffff"
>∑</text>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -156,7 +156,7 @@ function pageFor(
case "errors": case "errors":
return <Errors />; return <Errors />;
case "records": case "records":
return <Records />; return <Records onNavigate={navigate} />;
case "daily": case "daily":
return ( return (
<Daily <Daily

View File

@ -18,6 +18,7 @@ import type {
Overview, Overview,
PracticeSession, PracticeSession,
QuestionBankItem, QuestionBankItem,
QuestionBankSummary,
QuestionUpdatePayload, QuestionUpdatePayload,
RecordDetail, RecordDetail,
RecordItem, RecordItem,
@ -36,6 +37,7 @@ import type {
KnowledgeGraphData, KnowledgeGraphData,
KnowledgeGraphEdge, KnowledgeGraphEdge,
KnowledgeGraphNode, KnowledgeGraphNode,
KnowledgeMapData,
KnowledgeResourceLink, KnowledgeResourceLink,
KnowledgeNodeCreatePayload, KnowledgeNodeCreatePayload,
KnowledgeNodeUpdatePayload, KnowledgeNodeUpdatePayload,
@ -335,6 +337,8 @@ export const api = {
`/api/question-bank/questions${query ? `?${query}` : ""}`, `/api/question-bank/questions${query ? `?${query}` : ""}`,
); );
}, },
bankSummary: () =>
request<QuestionBankSummary>("/api/question-bank/summary"),
deleteQuestion: (id: number) => deleteQuestion: (id: number) =>
request<{ deleted: number }>(`/api/question-bank/questions/${id}`, { request<{ deleted: number }>(`/api/question-bank/questions/${id}`, {
method: "DELETE", method: "DELETE",
@ -355,6 +359,7 @@ export const api = {
request<LlmTaskResult>(`/api/llm/tasks/${id}`), request<LlmTaskResult>(`/api/llm/tasks/${id}`),
knowledgeGraph: () => knowledgeGraph: () =>
request<KnowledgeGraphData>("/api/knowledge/graph"), request<KnowledgeGraphData>("/api/knowledge/graph"),
knowledgeMap: () => request<KnowledgeMapData>("/api/knowledge/map"),
createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) => createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) =>
request<KnowledgeGraphNode>("/api/knowledge/nodes", { request<KnowledgeGraphNode>("/api/knowledge/nodes", {
method: "POST", method: "POST",

View File

@ -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<number>;
/** 按掌握度着色;关闭时是纯结构视图 */
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<number, Placed>;
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<number, Placed>();
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<string, MsgKey> = {
前置: "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<number | null>(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<HTMLDivElement | null>(null);
const layout = useMemo(() => buildLayout(nodes), [nodes]);
// 选中/悬停的一跳邻居:关系一眼看得见,不需要自己顺着线找
const focusId = hoverId ?? selectedId ?? null;
const neighbours = useMemo(() => {
if (focusId === null) return null;
const ids = new Set<number>([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 <Empty description={t("graph.emptyDomain")} />;
}
const zoom = (delta: number) =>
setScale((value) =>
Math.min(SCALE_MAX, Math.max(SCALE_MIN, Number((value + delta).toFixed(2)))),
);
function startDrag(event: ReactPointerEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<div className="kg-wrap" style={{ height }}>
<div className="kg-toolbar">
<span className="kg-hint">{t("graph.canvasHint")}</span>
<span className="kg-zoom">
<Button
size="small"
type="text"
aria-label={t("graph.zoomOut")}
icon={<MinusOutlined />}
onClick={() => zoom(-0.1)}
/>
<span className="kg-zoom-value">{Math.round(scale * 100)}%</span>
<Button
size="small"
type="text"
aria-label={t("graph.zoomIn")}
icon={<PlusOutlined />}
onClick={() => zoom(0.1)}
/>
<Button
size="small"
type="text"
aria-label={t("graph.zoomReset")}
icon={<AimOutlined />}
onClick={() => {
setScale(1);
setOffset({ x: 0, y: 0 });
scrollerRef.current?.scrollTo({ left: 0, top: 0 });
}}
/>
</span>
</div>
<div
className="kg-canvas"
ref={scrollerRef}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerLeave={endDrag}
>
<div
className="kg-content"
style={{
width: layout.width,
height: layout.height,
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
}}
>
<svg
className="kg-edges"
width={layout.width}
height={layout.height}
aria-hidden="true"
>
<defs>
<marker
id="kg-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" className="kg-arrow-head" />
</marker>
</defs>
{drawnEdges.map(({ edge, d }) => {
const related = focusId === edge.source || focusId === edge.target;
const dim = dimmedIds?.has(edge.source) || dimmedIds?.has(edge.target);
return (
<path
key={`${edge.source}-${edge.target}-${edge.relation_type}`}
d={d}
className={`kg-edge kg-edge--${edge.relation_type}${
related ? " kg-edge--focus" : ""
}${dim ? " kg-edge--dim" : ""}${
focusId !== null && !related ? " kg-edge--faded" : ""
}`}
markerEnd={edge.relation_type === "前置" ? "url(#kg-arrow)" : undefined}
>
<title>{t(EDGE_LABEL[edge.relation_type] ?? "graph.relRelated")}</title>
</path>
);
})}
</svg>
{layout.domainBands.map((band) => (
<div
key={band.domain}
className="kg-band"
style={{ left: band.x, width: band.width }}
>
<span className="kg-band-label">
{domainLabel ? domainLabel(band.domain) : band.domain}
</span>
</div>
))}
{layout.columns.map((column) => (
<div
key={column.key}
className="kg-column-head"
style={{ left: column.x, top: TOP_PAD, width: COL_W, height: HEAD_H }}
onClick={() => column.head && onSelect?.(column.head)}
role={column.head ? "button" : undefined}
tabIndex={column.head ? 0 : undefined}
>
<span className="kg-column-name">{column.category}</span>
<span className="kg-column-meta">
{showMastery && column.mastery !== null
? `${column.mastery}%`
: column.questionCount > 0
? t("graph.columnQuestions", { n: column.questionCount })
: ""}
</span>
</div>
))}
{layout.columns.flatMap((column) =>
column.points.map((item) => {
const node = item.node;
const tone =
showMastery && node.practiced
? masteryBandColor(node.status ?? "")
: undefined;
return (
<Tooltip
key={node.id}
title={
showMastery
? `${node.name} · ${
node.practiced
? t("graph.masteryValue", { n: node.mastery ?? 0 })
: t("graph.notPracticed")
}`
: `${node.name} · ${t("graph.nodeCounts", {
questions: node.question_count ?? 0,
chapters: node.chapter_count ?? 0,
})}`
}
>
<div
className={nodeClass(node)}
style={{
left: item.x,
top: item.y,
width: COL_W,
height: CARD_H,
...(tone ? ({ "--node-tone": tone } as CSSProperties) : {}),
}}
onClick={() => onSelect?.(node)}
onMouseEnter={() => setHoverId(node.id)}
onMouseLeave={() => setHoverId((value) => (value === node.id ? null : value))}
role="button"
tabIndex={0}
>
<span className="kg-node-dot" />
<span className="kg-node-name">{node.name}</span>
{showMastery && node.practiced && (
<span
className="kg-node-mastery"
style={{ color: masteryProgressColor(node.mastery ?? 0) }}
>
{node.mastery}%
</span>
)}
</div>
</Tooltip>
);
}),
)}
</div>
</div>
<div className="kg-legend">
{showMastery ? (
<>
{["重点", "待加强", "基本掌握", "已掌握"].map((band) => (
<span key={band} className="kg-legend-item">
<i
className="kg-legend-dot"
style={{ background: masteryBandColor(band) }}
/>
{band}
</span>
))}
<span className="kg-legend-item">
<i className="kg-legend-dot kg-legend-dot--empty" />
{t("graph.notPracticed")}
</span>
</>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("graph.edgeLegend")}
</Typography.Text>
)}
</div>
</div>
);
}

View File

@ -23,13 +23,13 @@ export function OptionPicker({ options, value, onChange }: OptionPickerProps) {
return ( return (
<Button <Button
key={`${option}-${index}`} key={`${option}-${index}`}
type={selected ? "primary" : "default"}
block block
className={`option-row${selected ? " option-row--active" : ""}`} className={`option-row${selected ? " option-row--active" : ""}`}
onClick={() => onChange(index)} onClick={() => onChange(index)}
> >
<span className="option-row-letter">{optionLetter(index)}.</span> <span className="option-row-letter">{optionLetter(index)}.</span>
<RichText content={option} /> <RichText content={option} />
{selected && <CheckCircleFilled className="option-row-check" />}
</Button> </Button>
); );
})} })}

View File

@ -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<HTMLElement | null>,
keys: (string | number)[],
revision: unknown = 0,
): string | number | null {
const [active, setActive] = useState<string | number | null>(keys[0] ?? null);
const frame = useRef(0);
const sync = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const nodes = Array.from(
container.querySelectorAll<HTMLElement>("[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(数字 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<HTMLElement>(
`[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 (
<div className={`answer-sheet answer-sheet--${size}`}>
<div className="answer-sheet-chips">
{visible.map((item) => {
const active = String(activeKey) === String(item.key);
const chip = (
<button
type="button"
key={String(item.key)}
className={`answer-chip answer-chip--${item.status}${
active ? " answer-chip--active" : ""
}`}
onClick={() => onSelect?.(item.key)}
>
<span className="answer-chip-no">{item.label ?? item.key}</span>
{item.status === "correct" && (
<CheckCircleFilled className="answer-chip-icon" />
)}
{item.status === "wrong" && (
<CloseCircleFilled className="answer-chip-icon" />
)}
{item.status === "pending" && (
<MinusOutlined className="answer-chip-icon" />
)}
</button>
);
return item.hint ? (
<Tooltip key={String(item.key)} title={item.hint}>
{chip}
</Tooltip>
) : (
chip
);
})}
</div>
{pageCount > 1 && (
<div className="answer-sheet-pager">
<Button
type="text"
size="small"
icon={<LeftOutlined />}
aria-label={t("paper.prevPage")}
disabled={current <= 0}
onClick={() => goTo(current - 1)}
/>
<span className="answer-sheet-pager-text">
{t("paper.pageRange", {
start: start + 1,
end: start + visible.length,
total: items.length,
})}
</span>
<Button
type="text"
size="small"
icon={<RightOutlined />}
aria-label={t("paper.nextPage")}
disabled={current >= pageCount - 1}
onClick={() => goTo(current + 1)}
/>
</div>
)}
{extra}
{summary && <div className="answer-sheet-summary">{summary}</div>}
</div>
);
}
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 (
<article
className={`paper-question${status ? ` paper-question--${status}` : ""}${
active ? " paper-question--active" : ""
}${className ? ` ${className}` : ""}`}
data-paper-anchor={anchor === undefined ? undefined : `paper-${anchor}`}
>
<div className="paper-question-rail">
<span className="paper-question-no">{index}</span>
</div>
<div className="paper-question-main">
<Flex align="center" gap={8} wrap className="paper-question-head">
{statusLabel && <Tag color={statusTagColor(status)}>{statusLabel}</Tag>}
{tags}
{actions && <span className="paper-question-actions">{actions}</span>}
</Flex>
{stem && <div className="paper-stem">{stem}</div>}
{children && <div className="paper-body-slot">{children}</div>}
{footer && <div className="paper-answer">{footer}</div>}
</div>
</article>
);
}
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 (
<Typography.Text type="secondary">{t("paper.unanswered")}</Typography.Text>
);
}
if (correct) {
return (
<span className="answer-compare">
<Tag color={tagColor.positive} style={{ marginInlineEnd: 0 }}>
{t("paper.yourAnswerCorrect", { answer: letter(yourIndex) })}
</Tag>
</span>
);
}
return (
<Flex wrap gap={6} align="center" className="answer-compare">
<Tag color={tagColor.negative} style={{ marginInlineEnd: 0 }}>
{t("paper.yourAnswer", { answer: letter(yourIndex) })}
</Tag>
{(correctIndex ?? null) !== null && (
<Tag color={tagColor.positive} style={{ marginInlineEnd: 0 }}>
{t("paper.correctAnswer", { answer: letter(correctIndex as number) })}
</Tag>
)}
</Flex>
);
}
/** 解析折叠块:默认展开,答对的题可以只留一行。 */
export function ExplanationBlock({
content,
defaultOpen = true,
}: {
content: ReactNode;
defaultOpen?: boolean;
}) {
const t = useT();
const [open, setOpen] = useState(defaultOpen);
return (
<div className="paper-explain">
<button
type="button"
className="paper-explain-toggle"
onClick={() => setOpen((value) => !value)}
>
{t("question.analysis")}
<span className="paper-explain-caret">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="paper-explain-body">{content}</div>}
</div>
);
}

View File

@ -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<HTMLDivElement | null>(null);
const [filter, setFilter] = useState<ReviewFilter>("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 (
<div className="paper">
<div className="paper-head">
<Flex align="flex-start" justify="space-between" gap={12} wrap>
<div className="paper-head-main">
<div className="paper-title">{title}</div>
{meta && <div className="paper-meta">{meta}</div>}
</div>
<Flex align="center" gap={10} wrap className="paper-head-side">
{score && (
<div className="paper-score">
<span className="paper-score-value">
{score.score}
<span className="paper-score-total">/{score.total}</span>
</span>
<span className="paper-score-label">{t("paper.score")}</span>
</div>
)}
{actions}
{tools}
</Flex>
</Flex>
<div className="paper-head-tools">
<AnswerSheet
items={visible.map((item, index) => ({
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"
/>
<Flex align="center" gap={12} wrap>
{hasVerdict && wrongCount > 0 && (
<Segmented
size="small"
value={filter}
onChange={(value) => setFilter(value as ReviewFilter)}
options={[
{ value: "all", label: t("paper.filterAll", { n: items.length }) },
{ value: "wrong", label: t("paper.filterWrong", { n: wrongCount }) },
]}
/>
)}
<Flex align="center" gap={6}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("paper.expandExplanations")}
</Typography.Text>
<Switch size="small" checked={expandAll} onChange={setExpandAll} />
</Flex>
</Flex>
</div>
</div>
<div className="paper-body" ref={bodyRef}>
{items.length === 0 ? (
empty ?? <EmptyState description={t("common.noData")} />
) : (
pageItems.map((item) => {
const originalIndex = items.indexOf(item);
const verdict = reviewStatus(item.correct);
return (
<PaperQuestion
key={item.key}
index={originalIndex + 1}
anchor={item.key}
status={verdict}
active={String(active) === String(item.key)}
statusLabel={
item.correct === true
? t("records.correct")
: item.correct === false
? t("records.incorrect")
: undefined
}
tags={
<>
<KnowledgeTags names={item.knowledgeNames} />
{item.tags}
</>
}
actions={item.actions}
stem={
item.stem ? (
<RichText content={item.stem} />
) : (
<Alert type="info" showIcon title={legacyHint ?? t("question.legacy")} />
)
}
footer={
item.stem ? (
<>
<Flex align="center" justify="space-between" gap={10} wrap>
{item.correct === null || item.correct === undefined ? (
<Flex wrap gap={6}>
{(item.correctIndex ?? null) !== null && (
<Tag color={tagColor.positive} style={{ marginInlineEnd: 0 }}>
{t("paper.correctAnswer", {
answer: optionLetter(item.correctIndex as number),
})}
</Tag>
)}
</Flex>
) : (
<AnswerCompare
yourIndex={item.selected}
correctIndex={item.correctIndex}
correct={item.correct}
letter={optionLetter}
/>
)}
{item.explanation && !expandAll && item.correct !== false && (
<Typography.Link
style={{ fontSize: 12 }}
onClick={() => setExpandAll(true)}
>
{t("paper.showExplanation")}
</Typography.Link>
)}
</Flex>
{(expandAll || item.correct === false) && item.explanation && (
<div className="paper-explain-body">
<RichText content={item.explanation} />
</div>
)}
</>
) : null
}
>
{item.options && item.options.length > 0 && (
<div className="paper-options">
<OptionList
options={item.options}
selected={item.selected}
correctIndex={item.correctIndex}
revealCorrect={
Boolean(revealAnswers) ||
item.correct === true ||
item.correct === false
}
/>
</div>
)}
</PaperQuestion>
);
})
)}
</div>
</div>
);
}
/** 卷头常用的一段说明文字。 */
export function PaperMetaText({ children }: { children: ReactNode }) {
return <Typography.Text type="secondary">{children}</Typography.Text>;
}
/** 得分与正确率的横条,供记录列表侧栏复用。 */
export function ScoreChip({
correct,
total,
}: {
correct: number;
total: number;
}) {
const t = useT();
const wrong = total - correct;
if (total === 0) {
return <Tag style={{ marginInlineEnd: 0 }}>{t("paper.emptyPaper")}</Tag>;
}
if (wrong === 0) {
return (
<Tag color={tagColor.positive} style={{ marginInlineEnd: 0 }}>
{t("records.allCorrect")}
</Tag>
);
}
return (
<Tag color={tagColor.negative} style={{ marginInlineEnd: 0 }}>
{t("records.wrongN", { n: wrong })}
</Tag>
);
}
/** 空的试卷占位(未选择记录 / 未选择题目标时)。 */
export function PaperPlaceholder({
description,
action,
}: {
description: ReactNode;
action?: ReactNode;
}) {
return (
<div className="paper">
<div className="paper-body">
<EmptyState description={description} action={action} />
</div>
</div>
);
}
/** 次要按钮的简写,页面里回看/跳转动作会反复用到。 */
export function PaperLinkButton({
children,
onClick,
disabled,
}: {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
}) {
return (
<Button size="small" onClick={onClick} disabled={disabled}>
{children}
</Button>
);
}

View File

@ -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 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 RichText from "./RichText";
import { DataRow, DataRows, Panel, SectionTitle } from "./ui";
import { status, tagColor } from "../theme";
import { useT } from "../prefs"; import { useT } from "../prefs";
import { masteryTagColor, tagColor } from "../theme";
interface PracticeResultProps { interface PracticeResultProps {
session: PracticeSession; session: PracticeSession;
@ -25,53 +29,164 @@ export function PracticeResult({
onExit, onExit,
}: PracticeResultProps) { }: PracticeResultProps) {
const t = useT(); const t = useT();
const reviews = result.per_question.map((item) => ({ const bodyRef = useRef<HTMLDivElement | null>(null);
...item,
question: session.questions.find((q) => q.id === item.question_id), 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 ( return (
<Panel> <SplitView
<Alert side="right"
type={result.score === result.total ? "success" : "info"} storageKey="nex_math.split.test.aside"
showIcon defaultWidth={286}
title={t("result.score", { score: result.score, total: result.total })} aside={
description={t("result.summary", { accuracy: result.accuracy })} <Pane
/> title={t("result.sheet")}
<div style={{ marginTop: 20 }}> toolbar={
<SectionTitle>{t("result.review")}</SectionTitle> <AnswerSheet
</div> items={questions.map(({ item, index }) => ({
<DataRows> key: index,
{reviews.map((review, index) => ( label: index + 1,
<DataRow status: item.correct ? "correct" : "wrong",
key={`${review.question_id}-${index}`} }))}
avatar={ onSelect={(key) => scrollToQuestion(bodyRef.current, key)}
review.correct ? ( size="small"
<CheckCircleOutlined style={{ color: status.success, fontSize: 22 }} /> />
) : ( }
<CloseCircleOutlined style={{ color: status.error, fontSize: 22 }} /> footer={
) <Flex vertical gap={8}>
} <Button
title={ type="primary"
<Space wrap size={[4, 4]}> block
{review.question && <RichText content={review.question.stem} />} icon={<ReloadOutlined />}
<Tag color={review.correct ? tagColor.positive : tagColor.negative}> onClick={onRestart}
{review.correct ? t("result.correct") : t("result.wrong")} >
</Tag> {t("result.retry")}
</Space> </Button>
} <Button block icon={<DashboardOutlined />} onClick={onExit}>
description={<RichText content={review.explanation} />} {t("result.back")}
</Button>
</Flex>
}
>
<div className="result-score">
<span className="result-score-value tabular">
{result.score}
<span className="result-score-total">/{result.total}</span>
</span>
<span className="result-score-label">{t("paper.score")}</span>
</div>
<Progress
percent={result.accuracy}
format={(percent) => `${percent}%`}
status={result.accuracy === 100 ? "success" : "normal"}
/> />
))} <Flex gap={6} wrap style={{ marginTop: 4 }}>
</DataRows> <Tag color={tagColor.positive}>
<Flex gap={10} style={{ marginTop: 16 }}> {t("result.correctN", { n: result.score })}
<Button type="primary" icon={<ReloadOutlined />} onClick={onRestart}> </Tag>
{t("result.retry")} <Tag color={tagColor.negative}>
</Button> {t("result.wrongN", { n: wrong.length })}
<Button icon={<DashboardOutlined />} onClick={onExit}> </Tag>
{t("result.back")} </Flex>
</Button> {result.knowledge.length > 0 && (
</Flex> <div className="result-knowledge">
</Panel> <div className="master-group">{t("result.mastery")}</div>
{result.knowledge.map((point) => (
<Flex
key={point.knowledge_id}
align="center"
justify="space-between"
gap={8}
className="result-knowledge-row"
>
<span className="result-knowledge-name">{point.name}</span>
<Tag color={masteryTagColor(point.mastery)} style={{ marginInlineEnd: 0 }}>
{point.mastery}
</Tag>
</Flex>
))}
</div>
)}
</Pane>
}
main={
<div className="paper">
<div className="paper-head">
<div className="paper-head-row">
<div className="paper-head-main">
<div className="paper-title">{result.title || session.title}</div>
<div className="paper-meta">
<Typography.Text type="secondary">
{t("result.summary", { accuracy: result.accuracy })}
</Typography.Text>
</div>
</div>
</div>
</div>
<div className="paper-body" ref={bodyRef}>
{questions.map(({ item, index, question }) => (
<PaperQuestion
key={`${item.question_id}-${index}`}
index={index + 1}
anchor={index}
status={item.correct ? "correct" : "wrong"}
statusLabel={item.correct ? t("result.correct") : t("result.wrong")}
tags={
question ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{question.knowledge_name}
</Typography.Text>
) : null
}
stem={
question ? (
<RichText content={question.stem} />
) : (
<Typography.Text type="secondary">
{t("question.legacy")}
</Typography.Text>
)
}
footer={
<>
<AnswerCompare
yourIndex={item.selected}
correctIndex={item.correct_index}
correct={item.correct}
letter={optionLetter}
/>
{item.explanation && (
<div className="paper-explain-body">
<RichText content={item.explanation} />
</div>
)}
</>
}
>
{question && (
<div className="paper-options">
<OptionList
options={question.options}
selected={item.selected}
correctIndex={item.correct_index}
revealCorrect
/>
</div>
)}
</PaperQuestion>
))}
</div>
</div>
}
/>
); );
} }

View File

@ -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 { api } from "../api/client";
import type { PracticeSession, SubmitResult } from "../types"; 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 { OptionPicker } from "./Options";
import { PracticeResult } from "./PracticeResult"; import { PracticeResult } from "./PracticeResult";
import { EmptyState, useUi } from "./ui";
import RichText from "./RichText"; import RichText from "./RichText";
import { tagColor } from "../theme";
import { useT } from "../prefs"; import { useT } from "../prefs";
interface QuestionFlowProps { interface QuestionFlowProps {
@ -22,7 +33,7 @@ export default function QuestionFlow({
}: QuestionFlowProps) { }: QuestionFlowProps) {
const { message } = useUi(); const { message } = useUi();
const t = useT(); const t = useT();
const [index, setIndex] = useState(0); const bodyRef = useRef<HTMLDivElement | null>(null);
const [answers, setAnswers] = useState<(number | undefined)[]>( const [answers, setAnswers] = useState<(number | undefined)[]>(
session.questions.map(() => undefined), session.questions.map(() => undefined),
); );
@ -30,8 +41,18 @@ export default function QuestionFlow({
const [result, setResult] = useState<SubmitResult | null>(null); const [result, setResult] = useState<SubmitResult | null>(null);
const total = session.questions.length; const total = session.questions.length;
const question = session.questions[index]; const empty = total === 0;
const progress = total ? Math.round((index / total) * 100) : 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) { if (result) {
return ( return (
@ -44,28 +65,33 @@ export default function QuestionFlow({
); );
} }
function choose(optionIndex: number) { function choose(questionIndex: number, optionIndex: number) {
setAnswers((prev) => { setAnswers((prev) => {
const next = prev.slice(); const next = prev.slice();
next[index] = optionIndex; next[questionIndex] = optionIndex;
return next; return next;
}); });
} }
function jumpTo(key: string | number) {
scrollToQuestion(bodyRef.current, key);
}
async function finish() { async function finish() {
if (answers.some((value) => value === undefined)) { if (empty) return;
message.warning(t("flow.unfinished")); const firstMissing = answers.findIndex((value) => value === undefined);
if (firstMissing >= 0) {
message.warning(t("flow.unfinishedAt", { index: firstMissing + 1 }));
jumpTo(firstMissing);
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const payload = session.questions.map((q, i) => ({ const payload = session.questions.map((question, i) => ({
question_id: q.id, question_id: question.id,
selected: answers[i] as number, selected: answers[i] as number,
})); }));
setResult( setResult(await api.submit(session.mode, payload, session.chapter_id));
await api.submit(session.mode, payload, session.chapter_id),
);
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : t("flow.submitFailed")); message.error(err instanceof Error ? err.message : t("flow.submitFailed"));
} finally { } finally {
@ -73,45 +99,151 @@ export default function QuestionFlow({
} }
} }
if (!question) return null;
const isLast = index === total - 1;
return ( return (
<Panel> <SplitView
<Flex align="center" justify="space-between" gap={12} style={{ marginBottom: 8 }}> side="right"
<Tag color={tagColor.knowledge}>{session.title}</Tag> storageKey="nex_math.split.test.aside"
<Typography.Text type="secondary"> defaultWidth={268}
{t("flow.progressPrefix")} <b>{index + 1}</b> / {total}{" "} aside={
{t("flow.progressSuffix")} <Pane
</Typography.Text> title={t("flow.sheet")}
</Flex> extra={
<Progress percent={progress} showInfo={false} /> !empty && (
<div className="question-stem"> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
<RichText content={question.stem} /> {t("flow.answered", { answered, total })}
</div> </Typography.Text>
<OptionPicker )
options={question.options} }
value={answers[index]} toolbar={
onChange={choose} empty ? (
/> <Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
<Space style={{ marginTop: 20 }}> {t("flow.emptyPaperShort")}
<Button disabled={index === 0} onClick={() => setIndex((value) => value - 1)}> </Typography.Text>
{t("flow.prev")} ) : (
</Button> <AnswerSheet
{!isLast ? ( items={session.questions.map((_, i) => ({
<Button key: i,
type="primary" label: i + 1,
disabled={answers[index] === undefined} status: answers[i] === undefined ? "pending" : "done",
onClick={() => setIndex((value) => value + 1)} hint:
> answers[i] === undefined
{t("flow.next")} ? t("flow.unanswered")
</Button> : t("flow.answeredAt", {
) : ( index: i + 1,
<Button type="primary" loading={submitting} onClick={finish}> letter: String.fromCharCode(65 + (answers[i] as number)),
{t("flow.submit")} }),
</Button> }))}
)} activeKey={active}
</Space> onSelect={jumpTo}
</Panel> size="small"
/>
)
}
footer={
<Flex vertical gap={10}>
{!empty && (
<Progress
percent={progress}
showInfo={false}
status={progress === 100 ? "success" : "active"}
/>
)}
<Button
type="primary"
block
loading={submitting}
disabled={empty}
onClick={() => void finish()}
>
{t("flow.submit")}
</Button>
{!empty && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{progress === 100
? t("flow.readyToSubmit")
: t("flow.pendingN", { n: total - answered })}
</Typography.Text>
)}
</Flex>
}
>
{!empty && (
<>
{/* 当前题:滚动到哪一题、这题答了没有,不用去数题号 */}
<div className="sheet-current">
<span className="sheet-current-label">
{t("flow.currentNo", { index: activeIndex + 1 })}
</span>
<span
className={`sheet-current-state${
activeAnswered ? " sheet-current-state--done" : ""
}`}
>
{activeAnswered
? t("flow.currentPicked", {
letter: String.fromCharCode(65 + (activeAnswer as number)),
})
: t("flow.unanswered")}
</span>
</div>
{unansweredIndexes.length > 0 && unansweredIndexes.length < total && (
<button
type="button"
className="sheet-jump"
onClick={() => jumpTo(unansweredIndexes[0])}
>
{t("flow.jumpToUnanswered", {
index: unansweredIndexes[0] + 1,
})}
</button>
)}
</>
)}
</Pane>
}
main={
<div className="paper">
<div className="paper-head">
<div className="paper-title">{session.title}</div>
<div className="paper-meta">
{!empty && (
<Typography.Text type="secondary">
{t("flow.metaLine", { total, answered })}
</Typography.Text>
)}
</div>
</div>
<div className="paper-body" ref={bodyRef}>
{empty && <EmptyState description={t("flow.emptyPaper")} />}
{session.questions.map((question, i) => (
<PaperQuestion
key={`${question.id}-${i}`}
index={i + 1}
anchor={i}
active={String(active) === String(i)}
status={answers[i] === undefined ? "pending" : "done"}
statusLabel={
answers[i] === undefined ? t("flow.unanswered") : t("flow.answeredTag")
}
tags={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{question.knowledge_name}
</Typography.Text>
}
stem={<RichText content={question.stem} />}
>
<div className="paper-options">
<OptionPicker
options={question.options}
value={answers[i]}
onChange={(optionIndex) => choose(i, optionIndex)}
/>
</div>
</PaperQuestion>
))}
</div>
</div>
}
/>
); );
} }

View File

@ -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<HTMLDivElement | null>(null);
const [height, setHeight] = useState<number | undefined>(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<HTMLDivElement>) => {
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 = (
<div
className={`split-resizer${dragging ? " split-resizer--active" : ""}`}
onPointerDown={startDrag}
role="separator"
aria-orientation="vertical"
/>
);
const asidePane = (
<div className="split-aside" style={{ width }}>
{aside}
</div>
);
const mainPane = <div className="split-main">{main}</div>;
if (!wide) {
return (
<div className={`split-view split-view--stack${className ? ` ${className}` : ""}`}>
{asidePane}
{mainPane}
</div>
);
}
return (
<div
ref={ref}
className={`split-view split-view--${side}${className ? ` ${className}` : ""}`}
style={{ height }}
>
{side === "left" ? (
<>
{asidePane}
{resizer}
{mainPane}
</>
) : (
<>
{mainPane}
{resizer}
{asidePane}
</>
)}
</div>
);
}
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 (
<div className={`pane${flat ? " pane--flat" : ""}${className ? ` ${className}` : ""}`}>
{(title || extra || toolbar) && (
<div className="pane-head">
{(title || extra) && (
<div className="pane-head-row">
<div className="pane-title">{title}</div>
{extra && <div className="pane-extra">{extra}</div>}
</div>
)}
{toolbar && <div className="pane-toolbar">{toolbar}</div>}
</div>
)}
<div className="pane-body">{children}</div>
{footer && <div className="pane-foot">{footer}</div>}
</div>
);
}

View File

@ -75,8 +75,20 @@ export function Panel({ title, extra, children, muted, className }: PanelProps)
* 面板上方工具条:只放筛选控件与提示语。 * 面板上方工具条:只放筛选控件与提示语。
* 主操作按钮一律放在功能页面 header 右侧,数量统计交给分页条,工具条不再承载按钮。 * 主操作按钮一律放在功能页面 header 右侧,数量统计交给分页条,工具条不再承载按钮。
*/ */
export function Toolbar({ filters }: { filters?: ReactNode }) { export function Toolbar({
return <div className="panel-toolbar">{filters}</div>; filters,
extra,
}: {
filters?: ReactNode;
/** 挤到这一行最右侧(视图切换等) */
extra?: ReactNode;
}) {
return (
<div className="panel-toolbar">
{filters}
{extra && <div className="panel-toolbar-extra">{extra}</div>}
</div>
);
} }
/** 面板内的小节标题。 */ /** 面板内的小节标题。 */

View File

@ -7,17 +7,29 @@ interface ApiState<T> {
reload: () => void; reload: () => void;
} }
/**
* 取数钩子。`enabled: false` 时完全不发请求,也不置 loading,
* 让页面自己渲染引导空态——缺少前置条件(未选章节、未选教材)时
* 不该把「还没开始」渲染成红色的「加载失败」。
*/
export function useApi<T>( export function useApi<T>(
load: () => Promise<T>, load: () => Promise<T>,
deps: unknown[] = [], deps: unknown[] = [],
options?: { enabled?: boolean },
): ApiState<T> { ): ApiState<T> {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const loaderRef = useRef(load); const loaderRef = useRef(load);
loaderRef.current = load; loaderRef.current = load;
const enabled = options?.enabled ?? true;
useEffect(() => { useEffect(() => {
if (!enabled) {
setData(null);
setError(null);
return;
}
let alive = true; let alive = true;
setData(null); setData(null);
setError(null); setError(null);
@ -32,12 +44,12 @@ export function useApi<T>(
return () => { return () => {
alive = false; alive = false;
}; };
}, [version, ...deps]); }, [version, enabled, ...deps]);
return { return {
data, data,
error, error,
loading: data === null && error === null, loading: enabled && data === null && error === null,
reload: () => setVersion((v) => v + 1), reload: () => setVersion((v) => v + 1),
}; };
} }

View File

@ -295,9 +295,12 @@ const zh = {
"daily.nextStep": "下一步", "daily.nextStep": "下一步",
"daily.retest": "再做本章测试", "daily.retest": "再做本章测试",
"daily.openMap": "查看知识地图", "daily.openMap": "查看知识地图",
"chapterTest.selectChapterRequired": "请选择章节", "chapterTest.emptyNoTextbook": "还没有教材,暂时无法按章节组卷。请管理员在「教材管理」中添加教材并整理章节目录。",
"chapterTest.emptyNoChapters": "{name} 还没有章节目录。请管理员在「教材管理」里添加章节,并在「题库管理」把题目挂到章节下,之后就能按章节组卷练习。",
"chapterTest.emptyPickChapter": "在上方选一章,系统会按你的薄弱知识点组一张卷子。",
"chapterTest.title": "选择测试章节", "chapterTest.title": "选择测试章节",
"chapterTest.reshuffle": "换一套", "chapterTest.reshuffle": "换一套",
"chapterTest.countOption": "{n} 题",
"chapterTest.textbookPlaceholder": "选择教材", "chapterTest.textbookPlaceholder": "选择教材",
"chapterTest.chapterPlaceholder": "选择章节", "chapterTest.chapterPlaceholder": "选择章节",
"chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成", "chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成",
@ -323,8 +326,10 @@ const zh = {
"knowledge.relPrereqOut": "后续知识点", "knowledge.relPrereqOut": "后续知识点",
"knowledge.relRelated": "相关知识点", "knowledge.relRelated": "相关知识点",
"knowledge.masteryTitle": "知识点掌握度", "knowledge.masteryTitle": "知识点掌握度",
"knowledge.averageMastery": "平均掌握度",
"knowledge.practicedCount": "已练知识点",
"knowledge.focusCount": "待重点强化",
"knowledge.bandAll": "全部", "knowledge.bandAll": "全部",
"knowledge.emptyBand": "该档暂无知识点",
"knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节", "knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节",
"knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度", "knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度",
"knowledge.detailTitle": "知识点详情", "knowledge.detailTitle": "知识点详情",
@ -385,13 +390,27 @@ const zh = {
"llm.willSave": "将保存为:{provider} · {model}", "llm.willSave": "将保存为:{provider} · {model}",
"graph.nodeTitle": "{name}(章 {chapters} · 题 {questions})", "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.domainElementary": "初等数学",
"graph.domainAdvanced": "高等数学", "graph.domainAdvanced": "高等数学",
"graph.domainPlaceholder": "选择知识领域",
"graph.allCategories": "全部(完整图谱)",
"graph.add": "新增知识点", "graph.add": "新增知识点",
"graph.addRelation": "添加关系", "graph.addRelation": "添加关系",
"graph.detailTitle": "{name} · 详情", "graph.detailTitle": "{name} · 详情",
"graph.detailTitleEmpty": "知识点详情",
"graph.detailEmpty": "在上方图谱或树里点一个知识点,这里显示它的定义、关系、关联章节与课程。",
"graph.viewGraph": "图谱",
"graph.viewTree": "树形",
"graph.fieldDomain": "领域", "graph.fieldDomain": "领域",
"graph.fieldCategory": "分类", "graph.fieldCategory": "分类",
"graph.fieldUsage": "引用", "graph.fieldUsage": "引用",
@ -433,10 +452,15 @@ const zh = {
"graph.msgUnlinkFailed": "解除失败", "graph.msgUnlinkFailed": "解除失败",
"bank.colQuestion": "题目", "bank.colQuestion": "题目",
"bank.colSource": "来源 / 章节", "bank.colChapter": "章节",
"bank.generated": "AI 生成", "bank.chapterTree": "教材 / 章节",
"bank.builtin": "内置题目", "bank.expandAll": "展开全部",
"bank.collapseAll": "收起全部",
"bank.expandBook": "展开章节",
"bank.collapseBook": "收起章节",
"bank.noChaptersYet": "暂无章节",
"bank.noChapter": "未挂章节", "bank.noChapter": "未挂章节",
"bank.difficultyAll": "难度不限",
"bank.colDifficulty": "难度", "bank.colDifficulty": "难度",
"bank.colUsage": "使用状态", "bank.colUsage": "使用状态",
"bank.usedN": "已使用 {n} 次", "bank.usedN": "已使用 {n} 次",
@ -720,6 +744,72 @@ const zh = {
"api.taskKindUnsupported": "不支持的任务类型:{kind}", "api.taskKindUnsupported": "不支持的任务类型:{kind}",
"api.knowledgeSomeMissing": "部分知识点不存在", "api.knowledgeSomeMissing": "部分知识点不存在",
"api.ebookFileInvalid": "{name} 不是有效的 {format} 文件", "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; } as const;
export type MsgKey = keyof typeof zh; export type MsgKey = keyof typeof zh;
@ -1009,9 +1099,12 @@ const en: Record<MsgKey, string> = {
"daily.nextStep": "Next", "daily.nextStep": "Next",
"daily.retest": "Take this chapter's test again", "daily.retest": "Take this chapter's test again",
"daily.openMap": "Open knowledge map", "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.title": "Choose a chapter to test",
"chapterTest.reshuffle": "Reshuffle", "chapterTest.reshuffle": "Reshuffle",
"chapterTest.countOption": "{n} questions",
"chapterTest.textbookPlaceholder": "Select textbook", "chapterTest.textbookPlaceholder": "Select textbook",
"chapterTest.chapterPlaceholder": "Select chapter", "chapterTest.chapterPlaceholder": "Select chapter",
"chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank", "chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank",
@ -1036,8 +1129,10 @@ const en: Record<MsgKey, string> = {
"knowledge.relPrereqOut": "Follow-up points", "knowledge.relPrereqOut": "Follow-up points",
"knowledge.relRelated": "Related knowledge points", "knowledge.relRelated": "Related knowledge points",
"knowledge.masteryTitle": "Mastery by knowledge point", "knowledge.masteryTitle": "Mastery by knowledge point",
"knowledge.averageMastery": "Average mastery",
"knowledge.practicedCount": "Practiced points",
"knowledge.focusCount": "Needs focus",
"knowledge.bandAll": "All", "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.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.empty": "No mastery data yet — finish a chapter test and it will be tracked on the knowledge graph",
"knowledge.detailTitle": "Knowledge point", "knowledge.detailTitle": "Knowledge point",
@ -1097,13 +1192,27 @@ const en: Record<MsgKey, string> = {
"llm.autoPlaceholder": "Auto if blank", "llm.autoPlaceholder": "Auto if blank",
"llm.willSave": "Will be saved as: {provider} · {model}", "llm.willSave": "Will be saved as: {provider} · {model}",
"graph.nodeTitle": "{name} (ch {chapters} · q {questions})", "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.domainElementary": "Elementary mathematics",
"graph.domainAdvanced": "Advanced mathematics", "graph.domainAdvanced": "Advanced mathematics",
"graph.domainPlaceholder": "Select a domain",
"graph.allCategories": "All (full graph)",
"graph.add": "New knowledge point", "graph.add": "New knowledge point",
"graph.addRelation": "Add relation", "graph.addRelation": "Add relation",
"graph.detailTitle": "{name} · Details", "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.fieldDomain": "Domain",
"graph.fieldCategory": "Category", "graph.fieldCategory": "Category",
"graph.fieldUsage": "Used by", "graph.fieldUsage": "Used by",
@ -1144,10 +1253,15 @@ const en: Record<MsgKey, string> = {
"graph.msgUnlinked": "Link removed", "graph.msgUnlinked": "Link removed",
"graph.msgUnlinkFailed": "Unlink failed", "graph.msgUnlinkFailed": "Unlink failed",
"bank.colQuestion": "Question", "bank.colQuestion": "Question",
"bank.colSource": "Source / Chapter", "bank.colChapter": "Chapter",
"bank.generated": "AI-generated", "bank.chapterTree": "Textbooks / chapters",
"bank.builtin": "Built-in", "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.noChapter": "No chapter",
"bank.difficultyAll": "Any level",
"bank.colDifficulty": "Difficulty", "bank.colDifficulty": "Difficulty",
"bank.colUsage": "Usage", "bank.colUsage": "Usage",
"bank.usedN": "Used {n} times", "bank.usedN": "Used {n} times",
@ -1424,6 +1538,72 @@ const en: Record<MsgKey, string> = {
"api.taskKindUnsupported": "Unsupported task type: {kind}", "api.taskKindUnsupported": "Unsupported task type: {kind}",
"api.knowledgeSomeMissing": "Some knowledge points do not exist", "api.knowledgeSomeMissing": "Some knowledge points do not exist",
"api.ebookFileInvalid": "{name} is not a valid {format} file", "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"; let currentLang: Lang = "zh";

View File

@ -1,15 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Select, Space, Tag, Typography } from "antd"; import type { ReactNode } from "react";
import { import { Button, Select, Tag, Typography } from "antd";
BookOutlined, import { BookOutlined, ReloadOutlined } from "@ant-design/icons";
InfoCircleOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { api } from "../api/client"; import { api } from "../api/client";
import { AsyncState } from "../components/Async"; import { AsyncState } from "../components/Async";
import QuestionFlow from "../components/QuestionFlow"; import QuestionFlow from "../components/QuestionFlow";
import { useApi } from "../components/useApi"; 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 { useT } from "../prefs";
import { tagColor } from "../theme"; import { tagColor } from "../theme";
import type { ViewId } from "../nav"; import type { ViewId } from "../nav";
@ -21,6 +19,10 @@ interface ChapterTestProps {
onReadOnline?: (textbookId: number, page?: number) => void; 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({ export default function ChapterTest({
initialChapterId, initialChapterId,
onNavigate, onNavigate,
@ -35,6 +37,8 @@ export default function ChapterTest({
initialChapterId ?? undefined, initialChapterId ?? undefined,
); );
const [variant, setVariant] = useState(0); const [variant, setVariant] = useState(0);
/** 组卷题量:默认 6 题,用户可改(后端上限 10) */
const [count, setCount] = useState(DEFAULT_TEST_COUNT);
// 初始只有章节 id(从教材目录 / 今日任务跳转)时,先找到所属教材 // 初始只有章节 id(从教材目录 / 今日任务跳转)时,先找到所属教材
useEffect(() => { useEffect(() => {
@ -63,12 +67,12 @@ export default function ChapterTest({
if (first) setChapterId(first.id); if (first) setChapterId(first.id);
}, [currentBook, chapterId]); }, [currentBook, chapterId]);
// 组卷要先有章节:缺前置条件时干脆不发请求,交给下面的引导空态,
// 而不是 Promise.reject 把「还没选章节」显示成红色的「加载失败」。
const sessionState = useApi( const sessionState = useApi(
() => () => api.chapterTest(chapterId as number, variant, count),
chapterId [chapterId, variant, count],
? api.chapterTest(chapterId, variant, 5) { enabled: Boolean(chapterId) },
: Promise.reject(new Error(t("chapterTest.selectChapterRequired"))),
[chapterId, variant],
); );
function chooseBook(id: number) { function chooseBook(id: number) {
@ -81,42 +85,37 @@ export default function ChapterTest({
setVariant((value) => value + 1); 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 ( return (
<Page> <Page>
<Panel {/* 组卷条件收在上方一条,下面整块留给答题工作台 */}
title={t("chapterTest.title")} <div className="test-launcher">
extra={ <div className="test-launcher-row">
<Space size={8} wrap>
{onReadOnline && currentBook?.ebook_format && (
<Button
icon={<BookOutlined />}
onClick={() =>
onReadOnline(
currentBook.id,
currentChapter?.ebook_page || undefined,
)
}
>
{currentChapter?.ebook_page
? t("ebook.openAt", { page: currentChapter.ebook_page })
: t("resources.readOnline")}
</Button>
)}
<Button
icon={<ReloadOutlined />}
onClick={restart}
disabled={!chapterId}
>
{t("chapterTest.reshuffle")}
</Button>
</Space>
}
>
<Toolbar
filters={
<Space wrap size={12}>
<Select <Select
style={{ width: 280 }} className="test-launcher-select"
placeholder={t("chapterTest.textbookPlaceholder")} placeholder={t("chapterTest.textbookPlaceholder")}
value={textbookId} value={textbookId}
onChange={chooseBook} onChange={chooseBook}
@ -126,7 +125,7 @@ export default function ChapterTest({
}))} }))}
/> />
<Select <Select
style={{ width: 260 }} className="test-launcher-select"
placeholder={t("chapterTest.chapterPlaceholder")} placeholder={t("chapterTest.chapterPlaceholder")}
value={chapterId} value={chapterId}
onChange={(id) => { onChange={(id) => {
@ -138,37 +137,62 @@ export default function ChapterTest({
label: chapter.name, label: chapter.name,
}))} }))}
/> />
</Space> <Button icon={<ReloadOutlined />} onClick={restart} disabled={!chapterId}>
} {t("chapterTest.reshuffle")}
/> </Button>
<Select
className="test-launcher-count"
value={count}
onChange={(value) => setCount(value)}
options={TEST_COUNT_OPTIONS.map((value) => ({
value,
label: t("chapterTest.countOption", { n: value }),
}))}
/>
{onReadOnline && currentBook?.ebook_format && (
<Button
icon={<BookOutlined />}
onClick={() =>
onReadOnline(
currentBook.id,
currentChapter?.ebook_page || undefined,
)
}
>
{currentChapter?.ebook_page
? t("ebook.openAt", { page: currentChapter.ebook_page })
: t("resources.readOnline")}
</Button>
)}
</div>
{currentChapter && ( {currentChapter && (
<div style={{ marginTop: 12 }}> <div className="test-launcher-meta">
<Typography.Text type="secondary"> <span className="test-launcher-tags">
{currentChapter.summary || "—"}
</Typography.Text>
<Space size={[4, 4]} wrap style={{ marginTop: 6 }}>
<KnowledgeTags names={currentChapter.knowledge_names} /> <KnowledgeTags names={currentChapter.knowledge_names} />
{currentChapter.question_count === 0 && ( {currentChapter.question_count === 0 && (
<Tag color={tagColor.warning}> <Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
{t("chapterTest.noQuestions")} {t("chapterTest.noQuestions")}
</Tag> </Tag>
)} )}
{currentChapter.question_count > 0 && {currentChapter.question_count > 0 &&
currentChapter.question_count < 5 && ( currentChapter.question_count < 5 && (
<Tag color={tagColor.warning}> <Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
{t("chapterTest.fewQuestions", { n: currentChapter.question_count })} {t("chapterTest.fewQuestions", {
n: currentChapter.question_count,
})}
</Tag> </Tag>
)} )}
</Space> </span>
<Typography.Text
type="secondary"
className="test-launcher-note"
ellipsis={{ tooltip: note }}
>
{note}
</Typography.Text>
</div> </div>
)} )}
<div style={{ marginTop: 12 }}> </div>
<Hint>
<InfoCircleOutlined style={{ marginRight: 6 }} />
{t("chapterTest.logic")}
</Hint>
</div>
</Panel>
{sessionState.data ? ( {sessionState.data ? (
<QuestionFlow <QuestionFlow
@ -177,11 +201,19 @@ export default function ChapterTest({
onRestart={restart} onRestart={restart}
onExit={() => onNavigate("dashboard")} onExit={() => onNavigate("dashboard")}
/> />
) : textbooksState.error ? (
<AsyncState
loading={false}
error={textbooksState.error}
onRetry={textbooksState.reload}
/>
) : emptyGuide ? (
<PaperPlaceholder description={emptyGuide.description} />
) : ( ) : (
<AsyncState <AsyncState
loading={sessionState.loading} loading={sessionState.loading || textbooksState.loading}
error={sessionState.error} error={sessionState.error}
onRetry={() => setVariant((value) => value + 1)} onRetry={restart}
/> />
)} )}
</Page> </Page>

View File

@ -134,7 +134,8 @@ export default function Daily({ onStartTest, onReadOnline, onNavigate }: DailyPr
setSubmitError(null); setSubmitError(null);
try { try {
const result = await api.completeDaily(task.chapter_id, value); const result = await api.completeDaily(task.chapter_id, value);
setSubmitted(result); // 自评以刚提交的值为准:万一接口回读滞后,页面也不会显示成“已确认”或空自评
setSubmitted({ ...result, rating: result.rating || value });
setRating(value); setRating(value);
setDoneInfo({ setDoneInfo({
from: isEdit ? result.streak : (task.streak ?? 0), from: isEdit ? result.streak : (task.streak ?? 0),

View File

@ -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 { api } from "../api/client";
import { AsyncState } from "../components/Async"; 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 RichText from "../components/RichText";
import { useApi } from "../components/useApi"; import { useApi } from "../components/useApi";
import { import { EmptyState, KnowledgeTags } from "../components/ui";
DataTable,
EmptyState,
KnowledgeTags,
Panel,
SubText,
} from "../components/ui";
import { backendText, enumLabel } from "../i18n"; import { backendText, enumLabel } from "../i18n";
import { useT } from "../prefs"; import { useT } from "../prefs";
import { tagColor } from "../theme"; import { tagColor } from "../theme";
import type { ErrorEntry } from "../types"; 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() { export default function Errors() {
const t = useT(); const t = useT();
const { data, loading, error, reload } = useApi(api.errors); const { data, loading, error, reload } = useApi(api.errors);
const entries = useMemo(() => data ?? [], [data]);
const [keyword, setKeyword] = useState("");
const [knowledge, setKnowledge] = useState<string | undefined>(undefined);
const [selectedId, setSelectedId] = useState<number | null>(null);
const columns: ColumnsType<ErrorEntry> = [ const knowledgeOptions = useMemo(() => {
{ const names = new Set<string>();
title: t("errors.colRound"), entries.forEach((entry) => knowledgeOf(entry).forEach((name) => names.add(name)));
dataIndex: "source_label", return Array.from(names)
width: 170, .sort()
render: (value: string) => <b>{value}</b>, .map((name) => ({ value: name, label: name }));
}, }, [entries]);
{
title: t("errors.colQuestion"), const visible = useMemo(() => {
key: "stem", const text = keyword.trim().toLowerCase();
render: (_, entry) => return entries.filter((entry) => {
entry.stem ? ( if (knowledge && !knowledgeOf(entry).includes(knowledge)) return false;
<RichText content={entry.stem} compact /> if (!text) return true;
) : ( return (
<SubText as="span">{t("errors.legacyNoDetail")}</SubText> (entry.stem ?? "").toLowerCase().includes(text) ||
), entry.source_label.toLowerCase().includes(text)
}, );
{ });
title: t("errors.colKnowledge"), }, [entries, keyword, knowledge]);
key: "knowledge",
render: (_, entry) => ( useEffect(() => {
<KnowledgeTags if (visible.length === 0) {
names={ if (selectedId !== null) setSelectedId(null);
(entry.knowledge_names ?? []).length > 0 return;
? entry.knowledge_names }
: [entry.knowledge_name] if (selectedId === null || !visible.some((entry) => entry.id === selectedId)) {
} setSelectedId(visible[0].id);
/> }
), }, [visible, selectedId]);
},
{ const index = visible.findIndex((entry) => entry.id === selectedId);
title: t("errors.colDiagnosis"), const current = index >= 0 ? visible[index] : null;
dataIndex: "diagnosis",
width: 160, function step(offset: number) {
render: (value: string) => backendText(value), const next = visible[index + offset];
}, if (next) setSelectedId(next.id);
{ }
title: t("errors.colStatus"),
dataIndex: "status",
width: 100,
render: (value: string) => (
<Tag color={tagColor.negative}>{enumLabel(t, value)}</Tag>
),
},
{ title: t("errors.colDate"), dataIndex: "created_on", width: 120 },
];
return ( return (
<Panel> <SplitView
<AsyncState loading={loading} error={error} onRetry={reload} /> storageKey="nex_math.split.errors.aside"
{data && ( defaultWidth={330}
<DataTable<ErrorEntry> aside={
rowKey="id" <Pane
columns={columns} title={t("errors.asideTitle")}
dataSource={data} extra={
locale={{ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
emptyText: ( {t("errors.stat", { n: entries.length })}
<EmptyState description={t("errors.empty")} /> </Typography.Text>
), }
}} toolbar={
expandable={{ <>
expandedRowRender: (entry) => ( <Input
<QuestionDetail allowClear
stem={entry.stem} size="small"
options={entry.options} prefix={<SearchOutlined />}
selected={entry.selected} placeholder={t("errors.search")}
correctIndex={entry.correct_index} value={keyword}
explanation={entry.explanation} onChange={(event) => setKeyword(event.target.value)}
legacyHint={t("errors.legacyHint")} style={{ flex: "1 1 140px", minWidth: 130 }}
/> />
), <Select
}} allowClear
/> size="small"
)} showSearch
</Panel> placeholder={t("errors.filterKnowledge")}
value={knowledge}
onChange={setKnowledge}
options={knowledgeOptions}
style={{ flex: "1 1 130px", minWidth: 130 }}
/>
</>
}
>
<AsyncState loading={loading} error={error} onRetry={reload} compact />
{data && visible.length === 0 && (
<EmptyState compact description={t("errors.empty")} />
)}
{visible.map((entry, position) => (
<button
type="button"
key={entry.id}
className={`master-item${
entry.id === selectedId ? " master-item--active" : ""
}`}
onClick={() => setSelectedId(entry.id)}
>
<div className="master-item-title">
<span className="tabular" style={{ color: "var(--text-muted)" }}>
{String(position + 1).padStart(2, "0")}
</span>
<span style={{ flex: 1, minWidth: 0 }}>
{entry.stem ? (
<RichText content={shorten(entry.stem)} />
) : (
t("errors.legacyNoDetail")
)}
</span>
</div>
<div className="master-item-sub">
{entry.source_label} · {entry.created_on}
</div>
</button>
))}
</Pane>
}
main={
current ? (
<div className="paper">
<div className="paper-head">
<div className="paper-title">{t("errors.detailTitle")}</div>
<div className="paper-meta">
<PaperMetaText>
{t("errors.metaLine", {
source: current.source_label,
date: current.created_on,
diagnosis: backendText(current.diagnosis),
})}
</PaperMetaText>
</div>
</div>
<div className="paper-body">
<PaperQuestion
index={index + 1}
anchor={current.id}
status={current.stem ? "wrong" : "neutral"}
statusLabel={t("records.incorrect")}
tags={
<>
<KnowledgeTags names={knowledgeOf(current)} />
<Tag color={tagColor.info} style={{ marginInlineEnd: 0 }}>
{enumLabel(t, current.status)}
</Tag>
</>
}
stem={
current.stem ? (
<RichText content={current.stem} />
) : (
t("errors.legacyHint")
)
}
footer={
current.stem ? (
<>
<AnswerCompare
yourIndex={current.selected}
correctIndex={current.correct_index}
correct={false}
letter={optionLetter}
/>
{current.explanation && (
<div className="paper-explain-body">
<RichText content={current.explanation} />
</div>
)}
</>
) : null
}
>
{current.options && current.options.length > 0 && (
<div className="paper-options">
<OptionList
options={current.options}
selected={current.selected}
correctIndex={current.correct_index}
revealCorrect
/>
</div>
)}
</PaperQuestion>
</div>
<div className="pane-foot">
<Flex align="center" justify="space-between" gap={12} wrap>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("errors.position", { index: index + 1, total: visible.length })}
</Typography.Text>
<Flex gap={8}>
<Button
size="small"
icon={<ArrowLeftOutlined />}
disabled={index <= 0}
onClick={() => step(-1)}
>
{t("errors.prev")}
</Button>
<Button
size="small"
disabled={index < 0 || index >= visible.length - 1}
onClick={() => step(1)}
>
{t("errors.next")}
<ArrowRightOutlined />
</Button>
</Flex>
</Flex>
</div>
</div>
) : (
<div className="paper">
<AsyncState loading={loading} error={error} onRetry={reload} compact />
{!loading && !error && (
<div className="paper-body">
<EmptyState description={t("errors.empty")} />
</div>
)}
{!loading && !error && <PaperSpacer />}
</div>
)
}
/>
); );
} }
/** 空态时把页脚撑开,保持和详情态一样高。 */
function PaperSpacer() {
return <div style={{ flex: 1 }} />;
}
/** 清单里只显示题干第一句,避免整道题挤满侧栏。 */
function shorten(stem: string): string {
const plain = stem
.replace(/!\[[^\]]*\]\([^)\s]+[^)]*\)/g, "")
.replace(/\s+/g, " ")
.trim();
return plain.length > 42 ? `${plain.slice(0, 42)}…` : plain;
}

View File

@ -5,6 +5,7 @@ import {
Flex, Flex,
Form, Form,
Input, Input,
Segmented,
Select, Select,
Space, Space,
Tag, Tag,
@ -12,8 +13,10 @@ import {
} from "antd"; } from "antd";
import type { TreeDataNode } from "antd"; import type { TreeDataNode } from "antd";
import { import {
ApartmentOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
PartitionOutlined,
PlusOutlined, PlusOutlined,
ShareAltOutlined, ShareAltOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
@ -21,6 +24,7 @@ import { api } from "../api/client";
import { HeaderActions } from "../components/Layout"; import { HeaderActions } from "../components/Layout";
import { AsyncState } from "../components/Async"; import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi"; import { useApi } from "../components/useApi";
import KnowledgeGraphView from "../components/KnowledgeGraphView";
import { import {
EditorDrawer, EditorDrawer,
FormRow, FormRow,
@ -41,6 +45,10 @@ import type {
KnowledgeGraphNode, KnowledgeGraphNode,
} from "../types"; } from "../types";
/** 领域筛选取「全部」时的取值,取值本身不参与提交。 */
const ALL_DOMAINS = "all";
/** 树视图:按「包含」关系折叠成层级,和画布看的是同一份数据。 */
function buildTreeData( function buildTreeData(
nodes: KnowledgeGraphNode[], nodes: KnowledgeGraphNode[],
edges: KnowledgeGraphEdge[], edges: KnowledgeGraphEdge[],
@ -65,10 +73,10 @@ function buildTreeData(
return { return {
key: String(id), key: String(id),
title: tr("graph.nodeTitle", { title: tr("graph.nodeTitle", {
name: node.name, name: node.name,
chapters: node.chapter_count, chapters: node.chapter_count,
questions: node.question_count, questions: node.question_count,
}), }),
children: kids.length ? kids : undefined, children: kids.length ? kids : undefined,
}; };
} }
@ -76,6 +84,9 @@ function buildTreeData(
return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item)); return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item));
} }
/** 图谱 / 树两种呈现方式,切换器放在筛选行的右端。 */
type GraphViewMode = "graph" | "tree";
export default function KnowledgeGraph() { export default function KnowledgeGraph() {
const t = useT(); const t = useT();
@ -83,7 +94,7 @@ export default function KnowledgeGraph() {
const { data, loading, error, reload } = useApi(api.knowledgeGraph); const { data, loading, error, reload } = useApi(api.knowledgeGraph);
const coursesState = useApi(api.courses); const coursesState = useApi(api.courses);
const [domain, setDomain] = useState("初等"); const [domain, setDomain] = useState("初等");
const [categoryId, setCategoryId] = useState<number>(0); const [viewMode, setViewMode] = useState<GraphViewMode>("graph");
const [selected, setSelected] = useState<KnowledgeGraphNode | null>(null); const [selected, setSelected] = useState<KnowledgeGraphNode | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeGraphNode | null>(null); const [editing, setEditing] = useState<KnowledgeGraphNode | null>(null);
@ -102,79 +113,44 @@ export default function KnowledgeGraph() {
}, [data, selected]); }, [data, selected]);
const nodes = useMemo( const nodes = useMemo(
() => (data?.nodes ?? []).filter((node) => node.domain === domain), () =>
(data?.nodes ?? []).filter(
(node) => domain === ALL_DOMAINS || node.domain === domain,
),
[data, domain], [data, domain],
); );
const domainRoot = nodes.find( const visibleIds = useMemo(
(node) => node.name === (domain === "初等" ? "初等数学" : "高等数学"), () => new Set(nodes.map((node) => node.id)),
[nodes],
); );
const categoryRoots = useMemo( const visibleEdges = useMemo(
() => () =>
(data?.edges ?? []) (data?.edges ?? []).filter(
.filter( (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
(edge) => ),
edge.relation_type === "包含" && [data, visibleIds],
edge.source === domainRoot?.id,
)
.map((edge) => nodes.find((node) => node.id === edge.target))
.filter((node): node is KnowledgeGraphNode => Boolean(node)),
[data, nodes, domainRoot],
); );
/** 树视图的根:在本域内没有被「包含」指向的节点(通常就是域根)。 */
const subNodes = useMemo( const treeData = useMemo(() => {
() => { const roots = nodes
if (categoryId === 0) return nodes; .filter(
const children = new Map<number, number[]>(); (node) =>
(data?.edges ?? []).forEach((edge) => { !visibleEdges.some(
if (edge.relation_type === "包含") { (edge) =>
const list = children.get(edge.source) ?? []; edge.relation_type === "包含" && edge.target === node.id,
list.push(edge.target); ),
children.set(edge.source, list); )
} .map((node) => node.id);
}); return buildTreeData(nodes, visibleEdges, roots);
const ids = new Set<number>([categoryId]); }, [nodes, visibleEdges]);
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 nodeById = new Map(nodes.map((node) => [node.id, node])); const nodeById = new Map(nodes.map((node) => [node.id, node]));
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
const defaultCategory =
categoryRoots.find((node) => node.id === categoryId)?.name ?? "";
form.setFieldsValue({ form.setFieldsValue({
domain, domain: domain === ALL_DOMAINS ? "初等" : domain,
category: defaultCategory, category: selected?.category ?? "",
description: "", description: "",
}); });
setEditOpen(true); setEditOpen(true);
@ -329,62 +305,84 @@ export default function KnowledgeGraph() {
</HeaderActions> </HeaderActions>
<Toolbar <Toolbar
filters={ filters={
<> <Select
<Select value={domain}
value={domain} onChange={(value) => setDomain(value)}
onChange={(value) => { style={{ width: 150 }}
setDomain(value); options={[
setCategoryId(0); { value: ALL_DOMAINS, label: t("graph.domainAll") },
}} { value: "初等", label: t("graph.domainElementary") },
style={{ width: 120 }} { value: "高等", label: t("graph.domainAdvanced") },
options={[ ]}
{ value: "初等", label: t("graph.domainElementary") }, />
{ value: "高等", label: t("graph.domainAdvanced") }, }
]} extra={
/> <Segmented
<Select size="small"
style={{ width: 240 }} value={viewMode}
placeholder={t("graph.domainPlaceholder")} onChange={(value) => setViewMode(value as GraphViewMode)}
value={categoryId} options={[
onChange={(value) => { {
setCategoryId(value); value: "graph",
setSelected(null); label: t("graph.viewGraph"),
}} icon: <ApartmentOutlined />,
options={[ },
{ value: 0, label: t("graph.allCategories") }, {
...categoryRoots.map((node) => ({ value: "tree",
value: node.id, label: t("graph.viewTree"),
label: node.name, icon: <PartitionOutlined />,
})), },
]} ]}
/> />
</>
} }
/> />
<AsyncState loading={loading} error={error} onRetry={reload} /> <AsyncState loading={loading} error={error} onRetry={reload} />
{data && ( {data &&
<div className="tree-box"> (viewMode === "graph" ? (
<KnowledgeGraphView
nodes={nodes}
edges={visibleEdges}
selectedId={selected?.id ?? null}
onSelect={(node) => {
const full = data.nodes.find((item) => item.id === node.id);
setSelected(full ?? null);
}}
domainLabel={(value) =>
value === "高等"
? t("graph.domainAdvanced")
: t("graph.domainElementary")
}
/>
) : (
<div className="graph-tree">
<Tree <Tree
showLine showLine
selectable selectable
defaultExpandedKeys={expandedRootKeys} defaultExpandAll
treeData={treeData} treeData={treeData}
selectedKeys={selected ? [String(selected.id)] : []}
onSelect={(keys) => { onSelect={(keys) => {
const id = Number(keys[0]); const node =
const node = data?.nodes.find((item) => item.id === id); data.nodes.find((item) => item.id === Number(keys[0])) ??
if (node) setSelected(node); null;
setSelected(node);
}} }}
/> />
</div> </div>
)} ))}
</Panel> </Panel>
{selected && ( {/* 右侧信息面板常驻:不管有没有选中,图谱的宽度都不会跳一下 */}
<Panel <Panel
muted muted
className="graph-detail" className="graph-detail"
title={t("graph.detailTitle", { name: selected.name })} title={
extra={ selected
? t("graph.detailTitle", { name: selected.name })
: t("graph.detailTitleEmpty")
}
extra={
selected ? (
<RowActions <RowActions
actions={[ actions={[
{ {
@ -402,8 +400,13 @@ export default function KnowledgeGraph() {
}, },
]} ]}
/> />
} ) : undefined
> }
>
{!selected ? (
<Hint>{t("graph.detailEmpty")}</Hint>
) : (
<>
<Descriptions <Descriptions
size="small" size="small"
column={2} column={2}
@ -417,6 +420,7 @@ export default function KnowledgeGraph() {
chapters: selected.chapter_count, chapters: selected.chapter_count,
questions: selected.question_count, questions: selected.question_count,
}), }),
span: 2,
}, },
{ key: "desc", label: t("graph.fieldDescription"), children: selected.description || "—", span: 2 }, { key: "desc", label: t("graph.fieldDescription"), children: selected.description || "—", span: 2 },
]} ]}
@ -560,8 +564,9 @@ export default function KnowledgeGraph() {
</Space.Compact> </Space.Compact>
</div> </div>
</Space> </Space>
</Panel> </>
)} )}
</Panel>
<EditorDrawer <EditorDrawer
title={ title={

View File

@ -1,10 +1,11 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { import {
Button, Button,
Card, Card,
Col, Col,
Drawer, Drawer,
Flex, Flex,
Input,
Progress, Progress,
Row, Row,
Segmented, Segmented,
@ -17,10 +18,12 @@ import {
BookOutlined, BookOutlined,
ExperimentOutlined, ExperimentOutlined,
LinkOutlined, LinkOutlined,
SearchOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { api } from "../api/client"; import { api } from "../api/client";
import { AsyncState } from "../components/Async"; import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi"; import { useApi } from "../components/useApi";
import KnowledgeGraphView from "../components/KnowledgeGraphView";
import { import {
EmptyState, EmptyState,
Hint, Hint,
@ -31,11 +34,19 @@ import {
} from "../components/ui"; } from "../components/ui";
import { enumLabel, relationDirectionLabel } from "../i18n"; import { enumLabel, relationDirectionLabel } from "../i18n";
import { useT } from "../prefs"; import { useT } from "../prefs";
import { MASTERY_BANDS, masteryProgressColor, masteryTagColor, tagColor } from "../theme"; import {
MASTERY_BANDS,
masteryProgressColor,
masteryTagColor,
status,
tagColor,
} from "../theme";
import type { KnowledgeRelatedNode } from "../types"; import type { KnowledgeRelatedNode } from "../types";
/** 分档筛选里「不过滤」的取值。 */ /** 分档筛选里「不过滤」的取值。 */
const ALL_BANDS = "all"; const ALL_BANDS = "all";
/** 领域筛选里「不过滤」的取值。 */
const ALL_DOMAINS = "all";
interface KnowledgeMapProps { interface KnowledgeMapProps {
onStartChapterTest?: (chapterId: number) => void; onStartChapterTest?: (chapterId: number) => void;
@ -56,8 +67,10 @@ export default function KnowledgeMap({
}: KnowledgeMapProps) { }: KnowledgeMapProps) {
const t = useT(); const t = useT();
const { data, loading, error, reload } = useApi(api.knowledge); const { data, loading, error, reload } = useApi(api.knowledgeMap);
const [band, setBand] = useState<string>(initialBand ?? ALL_BANDS); const [band, setBand] = useState<string>(initialBand ?? ALL_BANDS);
const [domain, setDomain] = useState<string>(ALL_DOMAINS);
const [keyword, setKeyword] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const detailState = useApi( const detailState = useApi(
() => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)), () => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)),
@ -65,9 +78,50 @@ export default function KnowledgeMap({
); );
const detail = detailState.data; const detail = detailState.data;
const knowledge = data ?? []; const nodes = data?.nodes ?? [];
const points = const edges = data?.edges ?? [];
band === ALL_BANDS ? knowledge : knowledge.filter((item) => item.status === band); 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<number>();
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< const relatedGroups = (detail?.related ?? []).reduce<
Record<string, KnowledgeRelatedNode[]> Record<string, KnowledgeRelatedNode[]>
@ -81,7 +135,7 @@ export default function KnowledgeMap({
<Page> <Page>
<AsyncState loading={loading} error={error} onRetry={reload} /> <AsyncState loading={loading} error={error} onRetry={reload} />
{data && {data &&
(data.length > 0 ? ( (nodes.length > 0 ? (
<Panel title={t("knowledge.masteryTitle")}> <Panel title={t("knowledge.masteryTitle")}>
<Toolbar <Toolbar
filters={ filters={
@ -97,36 +151,60 @@ export default function KnowledgeMap({
})), })),
]} ]}
/> />
<Hint>{t("knowledge.hint")}</Hint> <Segmented
value={domain}
onChange={(value) => setDomain(String(value))}
options={[
{ value: ALL_DOMAINS, label: t("graph.domainAll") },
{ value: "初等", label: t("graph.domainElementary") },
{ value: "高等", label: t("graph.domainAdvanced") },
]}
/>
<Input
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder={t("graph.searchPlaceholder")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ width: 180 }}
/>
</> </>
} }
/> />
{points.length === 0 ? ( <Flex className="kg-summary" gap={32} wrap align="center">
<EmptyState description={t("knowledge.emptyBand")} /> <Statistic
) : ( title={t("knowledge.averageMastery")}
<Row gutter={[16, 16]}> value={average}
{points.map((item) => ( suffix="%"
<Col xs={24} md={12} key={item.id}> />
<Card <Statistic
size="small" title={t("knowledge.practicedCount")}
hoverable value={practiced.length}
onClick={() => setSelectedId(item.knowledge_id)} suffix={`/ ${nodes.length}`}
title={item.name} />
extra={ <Statistic
<Tag color={masteryTagColor(item.mastery)}> title={t("knowledge.focusCount")}
{enumLabel(t, item.status)} value={focusCount}
</Tag> styles={
} focusCount > 0 ? { content: { color: status.error } } : undefined
> }
<Progress />
percent={item.mastery} </Flex>
strokeColor={masteryProgressColor(item.mastery)} <KnowledgeGraphView
/> nodes={visibleNodes}
</Card> edges={visibleEdges}
</Col> selectedId={selectedId}
))} onSelect={(node) => setSelectedId(node.id)}
</Row> dimmedIds={dimmed}
)} showMastery
domainLabel={(value) =>
value === "高等"
? t("graph.domainAdvanced")
: t("graph.domainElementary")
}
/>
<Hint>{t("knowledge.hint")}</Hint>
</Panel> </Panel>
) : ( ) : (
<Panel> <Panel>

View File

@ -1,12 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert,
Button, Button,
Checkbox, Checkbox,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Progress, Progress,
Segmented,
Select, Select,
Space, Space,
Tag, Tag,
@ -16,24 +16,28 @@ import {
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { import {
DeleteOutlined, DeleteOutlined,
DownOutlined,
EditOutlined, EditOutlined,
RobotOutlined, RobotOutlined,
RightOutlined,
SearchOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { api } from "../api/client"; import { api } from "../api/client";
import { HeaderActions } from "../components/Layout"; import { HeaderActions } from "../components/Layout";
import { AsyncState } from "../components/Async"; import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi"; 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 { import {
DataTable, DataTable,
EditorDrawer, EditorDrawer,
EmptyState, EmptyState,
Hint,
KnowledgeTags, KnowledgeTags,
Panel,
RowActions, RowActions,
SectionTitle, SectionTitle,
SubText, SubText,
Toolbar,
rules, rules,
useUi, useUi,
} from "../components/ui"; } from "../components/ui";
@ -50,6 +54,9 @@ interface QuestionBankProps {
preset?: { textbook_id?: number; chapter_id?: number } | null; preset?: { textbook_id?: number; chapter_id?: number } | null;
} }
/** 题目浏览的两档视图:试卷卡片(默认)与管理员批量维护用的表格。 */
type BankView = "card" | "table";
export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) { export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) {
const t = useT(); const t = useT();
@ -62,6 +69,12 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
const [filterChapterId, setFilterChapterId] = useState<number | undefined>( const [filterChapterId, setFilterChapterId] = useState<number | undefined>(
() => preset?.chapter_id, () => preset?.chapter_id,
); );
const [keyword, setKeyword] = useState("");
const [difficulty, setDifficulty] = useState<number | undefined>(undefined);
const [usage, setUsage] = useState<"all" | "used" | "unused">("all");
/** 左侧书籍-章节树:默认折叠,只自动展开当前定位到的那本书。 */
const [openBooks, setOpenBooks] = useState<Set<number>>(() => new Set());
const [viewMode, setViewMode] = useState<BankView>("card");
const [genOpen, setGenOpen] = useState(false); const [genOpen, setGenOpen] = useState(false);
const [genBusy, setGenBusy] = useState(false); const [genBusy, setGenBusy] = useState(false);
const [genTextbookId, setGenTextbookId] = useState<number | undefined>(); const [genTextbookId, setGenTextbookId] = useState<number | undefined>();
@ -74,9 +87,6 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
const [genForm] = Form.useForm(); const [genForm] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const filterChapters =
textbooks.find((item) => item.id === filterTextbookId)?.chapters ?? [];
const listState = useApi( const listState = useApi(
() => () =>
api.bankQuestions({ api.bankQuestions({
@ -85,6 +95,10 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
}), }),
[filterTextbookId, filterChapterId], [filterTextbookId, filterChapterId],
); );
// 侧栏的「全部题目」必须是全库真实题量:各章 question_count 之和会漏掉
// 还没挂章节的题目,出现“全部题目 0 / 右栏共 18 题”这种自相矛盾。
const totalsState = useApi(api.bankSummary);
const totals = totalsState.data;
const llmState = useApi(api.llmSettings); const llmState = useApi(api.llmSettings);
const graphState = useApi(api.knowledgeGraph); const graphState = useApi(api.knowledgeGraph);
@ -95,6 +109,16 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
} }
}, [preset?.textbook_id, preset?.chapter_id]); }, [preset?.textbook_id, preset?.chapter_id]);
// 选中的书自动展开,其它书保持折叠,避免几十个章节一次性铺开
useEffect(() => {
if (!filterTextbookId) return;
setOpenBooks((prev) =>
prev.has(filterTextbookId)
? prev
: new Set(prev).add(filterTextbookId),
);
}, [filterTextbookId]);
useEffect(() => { useEffect(() => {
if (!genOpen || !llmState.data) return; if (!genOpen || !llmState.data) return;
const defaultChannel = llmState.data.channels.find((channel) => channel.is_default); const defaultChannel = llmState.data.channels.find((channel) => channel.is_default);
@ -124,9 +148,33 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
setGenOpen(true); setGenOpen(true);
} }
function handleFilterTextbookChange(textbookId?: number) { function pickChapter(textbookId?: number, chapterId?: number) {
setFilterTextbookId(textbookId); 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) { function handleGenTextbookChange(textbookId: number) {
@ -227,6 +275,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
if (next.status === "success") { if (next.status === "success") {
message.success(backendText(next.message)); message.success(backendText(next.message));
listState.reload(); listState.reload();
textbooksState.reload();
totalsState.reload();
} else { } else {
message.error(backendText(next.message)); message.error(backendText(next.message));
} }
@ -241,6 +291,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
await api.deleteQuestion(item.id); await api.deleteQuestion(item.id);
message.success(t("common.msgDeleted")); message.success(t("common.msgDeleted"));
listState.reload(); listState.reload();
textbooksState.reload();
totalsState.reload();
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : t("common.msgDeleteFailed")); 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 })); message.success(t("bank.msgUpdated", { id: updated.id }));
setEditOpen(false); setEditOpen(false);
listState.reload(); listState.reload();
totalsState.reload();
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : t("common.msgSaveFailed")); message.error(err instanceof Error ? err.message : t("common.msgSaveFailed"));
} finally { } finally {
@ -303,7 +356,112 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
} }
} }
const columns: ColumnsType<QuestionBankItem> = [ 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 (
<RowActions
actions={[
{
key: "edit",
label: t("common.edit"),
icon: <EditOutlined />,
onClick: () => openEdit(item),
},
used
? {
key: "delete",
label: t("common.delete"),
icon: <DeleteOutlined />,
danger: true,
disabled: true,
tooltip: t("bank.deleteBlocked"),
onClick: () => undefined,
}
: {
kind: "confirm" as const,
key: "delete",
label: t("common.delete"),
icon: <DeleteOutlined />,
danger: true,
title: t("bank.deleteTitle"),
description: <RichText content={item.stem} />,
onConfirm: () => remove(item),
},
]}
/>
);
}
function questionTags(item: QuestionBankItem) {
return (
<>
<Tag color={tagColor.info} style={{ marginInlineEnd: 0 }}>
{t("bank.difficultyValue", { stars: "★".repeat(item.difficulty) })}
</Tag>
{(item.used_count ?? 0) > 0 ? (
<Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
{t("bank.usedN", { n: item.used_count })}
</Tag>
) : (
<Tag style={{ marginInlineEnd: 0 }}>{t("bank.unused")}</Tag>
)}
{!filterChapterId && (
<Tag color={tagColor.knowledge} style={{ marginInlineEnd: 0 }}>
{item.chapter_name || t("bank.noChapter")}
</Tag>
)}
</>
);
}
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<QuestionBankItem> = [
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ {
title: t("bank.colQuestion"), title: t("bank.colQuestion"),
@ -316,9 +474,9 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
}, },
{ {
title: t("analytics.knowledge"), title: t("analytics.knowledge"),
dataIndex: "knowledge_name", key: "knowledge",
width: 220, width: 200,
render: (_value: string, item) => ( render: (_, item) => (
<KnowledgeTags <KnowledgeTags
names={ names={
item.knowledge_names?.length > 0 item.knowledge_names?.length > 0
@ -329,30 +487,23 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
), ),
}, },
{ {
title: t("bank.colSource"), title: t("bank.colChapter"),
key: "origin", dataIndex: "chapter_name",
width: 120, width: 160,
render: (_, item) => ( render: (_, item) => (
<Space orientation="vertical" size={4}> <SubText as="span">{item.chapter_name || t("bank.noChapter")}</SubText>
<Tag color={item.is_generated ? tagColor.positive : tagColor.neutral}>
{item.is_generated ? t("bank.generated") : t("bank.builtin")}
</Tag>
<SubText as="span">{item.chapter_name || t("bank.noChapter")}</SubText>
</Space>
), ),
}, },
{ {
title: t("bank.colDifficulty"), title: t("bank.colDifficulty"),
dataIndex: "difficulty", dataIndex: "difficulty",
width: 90, width: 90,
render: (value: number) => ( render: (value: number) => <Tag color={tagColor.info}>{"★".repeat(value)}</Tag>,
<Tag color={tagColor.info}>{"★".repeat(value)}</Tag>
),
}, },
{ {
title: t("bank.colUsage"), title: t("bank.colUsage"),
dataIndex: "used_count", dataIndex: "used_count",
width: 130, width: 120,
render: (value: number) => render: (value: number) =>
(value ?? 0) > 0 ? ( (value ?? 0) > 0 ? (
<Tag color={tagColor.warning}>{t("bank.usedN", { n: value })}</Tag> <Tag color={tagColor.warning}>{t("bank.usedN", { n: value })}</Tag>
@ -363,160 +514,309 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
{ {
title: t("common.actions"), title: t("common.actions"),
key: "action", key: "action",
width: 140, width: 150,
fixed: "right", fixed: "right",
render: (_, item) => { render: (_, item) => questionActions(item),
const used = (item.used_count ?? 0) > 0;
return (
<RowActions
actions={[
{
key: "edit",
label: t("common.edit"),
icon: <EditOutlined />,
onClick: () => openEdit(item),
},
used
? {
key: "delete",
label: t("common.delete"),
icon: <DeleteOutlined />,
danger: true,
disabled: true,
tooltip: t("bank.deleteBlocked"),
onClick: () => undefined,
}
: {
kind: "confirm" as const,
key: "delete",
label: t("common.delete"),
icon: <DeleteOutlined />,
danger: true,
title: t("bank.deleteTitle"),
description: <RichText content={item.stem} />,
onConfirm: () => remove(item),
},
]}
/>
);
},
}, },
]; ];
const viewToggle = (
<Segmented
size="small"
value={viewMode}
onChange={(value) => setViewMode(value as BankView)}
options={[
{ value: "card", label: t("bank.viewCard") },
{ value: "table", label: t("bank.viewTable") },
]}
/>
);
const emptyHint = (
<EmptyState
description={
questions.length === 0
? filterChapterId
? t("bank.emptyFiltered")
: t("bank.empty")
: t("bank.noMatch")
}
action={
<Button icon={<RobotOutlined />} onClick={openGenerate}>
{t("bank.generate")}
</Button>
}
/>
);
return ( return (
<> <>
<Panel> <HeaderActions>
<HeaderActions> <Button
<Button type="primary"
type="primary" icon={<RobotOutlined />}
icon={<RobotOutlined />} disabled={textbooks.length === 0}
disabled={textbooks.length === 0} onClick={openGenerate}
onClick={openGenerate} >
> {t("bank.generate")}
{t("bank.generate")} </Button>
</Button> </HeaderActions>
</HeaderActions>
<Toolbar {activeTask && (
filters={ <div className="bank-task">
<> <SectionTitle>{t("bank.taskTitle", { id: activeTask.id })}</SectionTitle>
<Select <Progress
allowClear percent={activeTask.progress}
placeholder={t("bank.allTextbooks")} status={
style={{ width: 260 }} activeTask.status === "failed"
value={filterTextbookId} ? "exception"
onChange={handleFilterTextbookChange} : activeTask.status === "success"
options={textbooks.map((book) => ({ ? "success"
value: book.id, : "active"
label: book.name, }
}))}
/>
<Select
allowClear
placeholder={t("bank.allChapters")}
style={{ width: 220 }}
value={filterChapterId}
onChange={setFilterChapterId}
disabled={!filterTextbookId || filterChapters.length === 0}
options={filterChapters.map((chapter) => ({
value: chapter.id,
label: chapter.name,
}))}
/>
</>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={
<>
{t("bank.hintPrefix")}
<Typography.Link onClick={() => onNavigate("llm")}>
{t("bank.hintLink")}
</Typography.Link>
{t("bank.hintSuffix")}
</>
}
/>
<AsyncState
loading={textbooksState.loading}
error={textbooksState.error}
onRetry={textbooksState.reload}
/>
{activeTask && (
<div style={{ marginBottom: 16 }}>
<SectionTitle>{t("bank.taskTitle", { id: activeTask.id })}</SectionTitle>
<Progress
percent={activeTask.progress}
status={
activeTask.status === "failed"
? "exception"
: activeTask.status === "success"
? "success"
: "active"
}
/>
<SubText>{backendText(activeTask.message)}</SubText>
</div>
)}
<AsyncState
loading={listState.loading}
error={listState.error}
onRetry={listState.reload}
/>
{listState.data && (
<DataTable<QuestionBankItem>
rowKey="id"
columns={columns}
scroll={{ x: 1240 }}
dataSource={listState.data}
locale={{
emptyText: (
<EmptyState
description={
filterTextbookId
? t("bank.emptyFiltered")
: t("bank.empty")
}
/>
),
}}
expandable={{
expandedRowRender: (item) => (
<QuestionDetail
stem={item.stem}
options={item.options}
correctIndex={item.correct_index}
explanation={item.explanation}
revealCorrect={false}
showStem={false}
/>
),
}}
/> />
)} <SubText>{backendText(activeTask.message)}</SubText>
</Panel> </div>
)}
<SplitView
storageKey="nex_math.split.bank.aside"
defaultWidth={340}
aside={
<Pane
title={t("bank.asideTitle")}
toolbar={
<>
<Input
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder={t("bank.search")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ width: "100%" }}
/>
<Select
allowClear
size="small"
popupMatchSelectWidth={false}
placeholder={t("bank.difficultyAll")}
value={difficulty}
onChange={setDifficulty}
className="bank-filter-select"
aria-label="bank.colDifficulty"
options={[
{ value: 1, label: t("bank.difficultyBasic") },
{ value: 2, label: t("bank.difficultyMedium") },
{ value: 3, label: t("bank.difficultyHard") },
]}
/>
<Select
size="small"
popupMatchSelectWidth={false}
value={usage}
onChange={setUsage}
className="bank-filter-select"
aria-label="bank.colUsage"
options={[
{ value: "all", label: t("bank.usageAll") },
{ value: "used", label: t("bank.filterUsed") },
{ value: "unused", label: t("bank.filterUnused") },
]}
/>
</>
}
>
<AsyncState
loading={textbooksState.loading}
error={textbooksState.error}
onRetry={textbooksState.reload}
compact
/>
<button
type="button"
className={`master-item${
!filterTextbookId ? " master-item--active" : ""
}`}
onClick={() => pickChapter(undefined, undefined)}
>
<div className="master-item-title">
<span style={{ flex: 1, minWidth: 0 }}>{t("bank.navAll")}</span>
<span className="chapter-nav-count">{totals?.total ?? 0}</span>
</div>
{(totals?.unattached ?? 0) > 0 && (
<div className="master-item-sub">
{t("bank.unattachedHint", { n: totals?.unattached ?? 0 })}
</div>
)}
</button>
<div className="master-tree-head">
<span>{t("bank.chapterTree")}</span>
{booksWithChapters.length > 0 && (
<button
type="button"
className="master-tree-toggle"
onClick={toggleAllBooks}
>
{allBooksOpen ? t("bank.collapseAll") : t("bank.expandAll")}
</button>
)}
</div>
{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 (
<div className="master-book" key={book.id}>
<div
className={`master-book-row${
bookActive ? " master-book-row--active" : ""
}`}
>
<button
type="button"
className="master-caret"
aria-expanded={open}
aria-label={open ? t("bank.collapseBook") : t("bank.expandBook")}
title={open ? t("bank.collapseBook") : t("bank.expandBook")}
disabled={!hasChapters}
onClick={() => toggleBook(book.id)}
>
{hasChapters ? (
open ? <DownOutlined /> : <RightOutlined />
) : (
<span className="master-caret-empty">·</span>
)}
</button>
<button
type="button"
className="master-book-button"
title={book.name}
onClick={() => pickChapter(book.id, undefined)}
>
<span className="master-book-name">{book.name}</span>
<span className="chapter-nav-count">{total}</span>
</button>
</div>
{open && (
<div className="master-chapters">
{book.chapters.map((chapter) => {
const active = filterChapterId === chapter.id;
const empty = chapter.question_count === 0;
return (
<button
type="button"
key={chapter.id}
className={`master-item master-item--tight${
active ? " master-item--active" : ""
}${empty ? " master-item--zero" : ""}`}
title={chapter.name}
onClick={() => pickChapter(book.id, chapter.id)}
>
<div className="master-item-title">
<span className="master-chapter-name">
{chapter.name}
</span>
<span className="chapter-nav-count">
{chapter.question_count}
</span>
</div>
</button>
);
})}
</div>
)}
{!hasChapters && (
<div className="master-book-empty">
{t("bank.noChaptersYet")}
</div>
)}
</div>
);
})}
<div className="aside-hint">
<Hint>
{t("bank.hintPrefix")}
<Typography.Link onClick={() => onNavigate("llm")}>
{t("bank.hintLink")}
</Typography.Link>
{t("bank.hintSuffix")}
</Hint>
</div>
</Pane>
}
main={
viewMode === "card" ? (
<PaperReview
revealAnswers
title={scopeTitle}
meta={
<>
<PaperMetaText>
{t("bank.metaLine", {
total: summary.total,
used: summary.used,
})}
</PaperMetaText>
{activeChapter?.summary && (
<>
<br />
<PaperMetaText>{activeChapter.summary}</PaperMetaText>
</>
)}
</>
}
tools={viewToggle}
items={reviewItems}
empty={emptyHint}
/>
) : (
<div className="paper">
<div className="paper-head">
<div className="paper-head-row">
<div className="paper-head-main">
<div className="paper-title">{scopeTitle}</div>
<div className="paper-meta">
<PaperMetaText>
{t("bank.metaLine", {
total: summary.total,
used: summary.used,
})}
</PaperMetaText>
</div>
</div>
<div>{viewToggle}</div>
</div>
</div>
<div className="paper-body">
<AsyncState
loading={listState.loading}
error={listState.error}
onRetry={listState.reload}
/>
{listState.data && (
<DataTable<QuestionBankItem>
rowKey="id"
columns={tableColumns}
scroll={{ x: 1180 }}
dataSource={filtered}
locale={{ emptyText: emptyHint }}
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => t("common.totalRows", { n: total }),
}}
/>
)}
</div>
</div>
)
}
/>
<EditorDrawer <EditorDrawer
title={t("bank.editTitle", { id: editItem?.id ?? "" })} title={t("bank.editTitle", { id: editItem?.id ?? "" })}

View File

@ -1,167 +1,240 @@
import { useState } from "react"; /**
import { Alert, Button, Progress, Space, Tag } from "antd"; * 测试记录:左侧历次训练清单,右侧按试卷版式逐题回看。
import type { ColumnsType } from "antd/es/table"; *
* 记录本身是一整套卷子,用表格展开行的方式回看会把题目压成一行,
* 所以这里改成「清单 + 试卷」分屏:清单常驻可搜,试卷独立滚动。
*/
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Flex, Input, Progress, Tag, Typography } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { api } from "../api/client"; import { api } from "../api/client";
import { AsyncState } from "../components/Async"; import { AsyncState } from "../components/Async";
import { QuestionDetail } from "../components/QuestionDetail"; import { Pane, SplitView } from "../components/SplitView";
import { PaperMetaText, PaperPlaceholder, PaperReview, ScoreChip } from "../components/PaperReview";
import type { ReviewItem } from "../components/PaperReview";
import { useApi } from "../components/useApi"; import { useApi } from "../components/useApi";
import { import { EmptyState } from "../components/ui";
DataTable, import type { ViewId } from "../nav";
EmptyState,
Hint,
KnowledgeTags,
Panel,
SubText,
Toolbar,
} from "../components/ui";
import { useT } from "../prefs"; import { useT } from "../prefs";
import { tagColor } from "../theme"; import { tagColor } from "../theme";
import type { AttemptItemDetail, RecordDetail, RecordItem } from "../types"; import type { RecordDetail, RecordItem } from "../types";
function ItemView({ item }: { item: AttemptItemDetail }) { interface RecordsProps {
const t = useT(); onNavigate?: (view: ViewId) => void;
return (
<Space orientation="vertical" size={10} style={{ width: "100%" }}>
<Space wrap size={[4, 4]}>
<Tag color={item.correct ? tagColor.positive : tagColor.negative}>
{item.correct ? t("records.correct") : t("records.incorrect")}
</Tag>
<KnowledgeTags
names={
item.knowledge_names?.length
? item.knowledge_names
: [item.knowledge_name]
}
/>
</Space>
<QuestionDetail
stem={item.stem}
options={item.options}
selected={item.selected}
correctIndex={item.correct_index}
explanation={item.explanation}
revealCorrect={item.correct === false}
/>
</Space>
);
} }
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 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<number | null>(null);
const [details, setDetails] = useState<Record<number, RecordDetail>>({}); const [details, setDetails] = useState<Record<number, RecordDetail>>({});
const [loadingId, setLoadingId] = useState<number | null>(null); const [pendingId, setPendingId] = useState<number | null>(null);
const [detailErrors, setDetailErrors] = useState<Record<number, string>>({}); const [detailErrors, setDetailErrors] = useState<Record<number, string>>({});
async function loadDetail(record: RecordItem) { const visible = useMemo(() => {
if (details[record.id] || loadingId !== null) return; const text = keyword.trim().toLowerCase();
setLoadingId(record.id); 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) => { setDetailErrors((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[record.id]; delete next[id];
return next; return next;
}); });
try { try {
const detail = await api.recordDetail(record.id); const detail = await api.recordDetail(id);
setDetails((prev) => ({ ...prev, [record.id]: detail })); setDetails((prev) => ({ ...prev, [id]: detail }));
} catch (err) { } catch {
setDetailErrors((prev) => ({ setDetailErrors((prev) => ({ ...prev, [id]: t("common.loadFailed") }));
...prev,
[record.id]: t("common.loadFailed"),
}));
} finally { } finally {
setLoadingId(null); setPendingId(null);
} }
} }
const columns: ColumnsType<RecordItem> = [ useEffect(() => {
{ title: t("records.colTime"), dataIndex: "happened_on", width: 130 }, if (selectedId !== null) void loadDetail(selectedId);
{ title: t("records.colTraining"), dataIndex: "title" }, // 选中变化时补拉明细,其余依赖都是稳定引用
{ title: t("records.colTotal"), dataIndex: "total", width: 90, align: "center" }, // eslint-disable-next-line react-hooks/exhaustive-deps
{ title: t("records.colCorrect"), dataIndex: "correct", width: 90, align: "center" }, }, [selectedId]);
{
title: t("records.colRate"), const selected = selectedId === null ? null : (details[selectedId] ?? null);
key: "accuracy", const selectedError = selectedId === null ? null : detailErrors[selectedId];
width: 240, const loadingSelected =
render: (_: unknown, record) => { selectedId !== null && !selected && !selectedError && pendingId === selectedId;
const percent = record.total
? Math.round((record.correct / record.total) * 100) const items: ReviewItem[] = (selected?.items ?? []).map((item) => ({
: 0; key: `${item.question_id ?? "q"}-${item.question_index}`,
return ( stem: item.stem,
<Space> options: item.options,
<Progress selected: item.selected,
percent={percent} correctIndex: item.correct_index,
size="small" explanation: item.explanation,
style={{ width: 120, margin: 0 }} correct: item.correct,
/> knowledgeNames: item.knowledge_names?.length
{record.total - record.correct > 0 ? ( ? item.knowledge_names
<Tag color={tagColor.negative}>{t("records.wrongN", { n: record.total - record.correct })}</Tag> : [item.knowledge_name],
) : ( tags:
<Tag color={tagColor.positive}>{t("records.allCorrect")}</Tag> (item.correct === false) ? (
)} <Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
</Space> {t("records.needsReview")}
); </Tag>
}, ) : 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 ( return (
<Panel> <SplitView
<Toolbar filters={<Hint>{t("records.expandHint")}</Hint>} /> storageKey="nex_math.split.records.aside"
<AsyncState loading={loading} error={error} onRetry={reload} /> defaultWidth={318}
{data && ( aside={
<DataTable<RecordItem> <Pane
rowKey="id" title={t("records.asideTitle")}
columns={columns} extra={
dataSource={data} <Typography.Text type="secondary" style={{ fontSize: 12 }}>
locale={{ {t("records.stat", {
emptyText: ( sessions: stats.sessions,
<EmptyState description={t("records.empty")} /> accuracy: stats.accuracy,
), })}
}} </Typography.Text>
expandable={{ }
onExpand: (expanded, record) => { toolbar={
if (expanded) void loadDetail(record); <Input
}, allowClear
expandedRowRender: (record) => { size="small"
if (loadingId === record.id) { prefix={<SearchOutlined />}
return <AsyncState loading compact />; placeholder={t("records.search")}
} value={keyword}
if (detailErrors[record.id]) { onChange={(event) => setKeyword(event.target.value)}
return ( />
<Alert }
type="error" >
showIcon <AsyncState loading={listState.loading} error={listState.error} onRetry={listState.reload} compact />
title={detailErrors[record.id]} {listState.data && visible.length === 0 && (
action={ <EmptyState compact description={t("records.empty")} />
<Button )}
size="small" {visible.map((record: RecordItem) => {
danger const percent = accuracyOf(record);
onClick={() => void loadDetail(record)} const active = record.id === selectedId;
> return (
{t("common.retry")} <button
</Button> type="button"
} key={record.id}
/> className={`master-item${active ? " master-item--active" : ""}`}
); onClick={() => setSelectedId(record.id)}
} >
const detail = details[record.id]; <div className="master-item-title">
if (!detail) { <span style={{ flex: 1, minWidth: 0 }}>{record.title}</span>
return <SubText>{t("records.detailPending")}</SubText>; <ScoreChip correct={record.correct} total={record.total} />
} </div>
return ( <div className="master-item-sub">
<Space orientation="vertical" size={14} style={{ width: "100%" }}> {record.happened_on} · {t("records.summary", {
{detail.items.map((item) => ( correct: record.correct,
<ItemView key={`${item.question_id}-${item.question_index}`} item={item} /> total: record.total,
))} })}
</Space> </div>
); <Progress
}, percent={percent}
}} size="small"
/> showInfo={false}
)} style={{ marginTop: 6, marginBottom: 0 }}
</Panel> />
</button>
);
})}
</Pane>
}
main={
selected ? (
<PaperReview
title={selected.title}
meta={
<PaperMetaText>
{t("records.metaLine", {
date: selected.happened_on,
total: selected.total,
correct: selected.correct,
accuracy: accuracyOf(selected),
})}
</PaperMetaText>
}
score={{ score: selected.correct, total: selected.total }}
items={items}
/>
) : loadingSelected ? (
<div className="paper">
<div className="paper-body">
<AsyncState loading compact />
</div>
</div>
) : selectedError ? (
<div className="paper">
<div className="paper-body">
<Alert
type="error"
showIcon
title={selectedError}
action={
selectedId !== null && (
<Button size="small" danger onClick={() => void loadDetail(selectedId)}>
{t("common.retry")}
</Button>
)
}
/>
</div>
</div>
) : (
<PaperPlaceholder
description={
records.length === 0 && onNavigate
? t("records.empty")
: t("records.pickOne")
}
action={
records.length === 0 && onNavigate ? (
<Flex justify="center">
<Button type="primary" onClick={() => onNavigate("test")}>
{t("records.startTest")}
</Button>
</Flex>
) : undefined
}
/>
)
}
/>
); );
} }

View File

@ -17,6 +17,9 @@
--track: #f1f2f6; --track: #f1f2f6;
--track-raised: #ffffff; --track-raised: #ffffff;
--brand-soft: rgba(91, 92, 226, 0.08); --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: #f0fdf4;
--success-soft-border: #bbf7d0; --success-soft-border: #bbf7d0;
--danger-soft: #fef2f2; --danger-soft: #fef2f2;
@ -47,6 +50,9 @@
--track: #24263c; --track: #24263c;
--track-raised: #3a3e5c; --track-raised: #3a3e5c;
--brand-soft: rgba(91, 92, 226, 0.22); --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: rgba(22, 163, 74, 0.14);
--success-soft-border: rgba(22, 163, 74, 0.4); --success-soft-border: rgba(22, 163, 74, 0.4);
--danger-soft: rgba(220, 38, 38, 0.14); --danger-soft: rgba(220, 38, 38, 0.14);
@ -606,6 +612,13 @@ body {
margin-bottom: 16px; margin-bottom: 16px;
} }
.panel-toolbar-extra {
display: flex;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.section-title { .section-title {
display: flex; display: flex;
@ -735,6 +748,22 @@ body {
opacity: 0.7; 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 { .question-stem {
font-size: 17px; font-size: 17px;
@ -779,6 +808,281 @@ body {
min-width: min(520px, 100%); 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 { .graph-detail {
width: 380px; width: 380px;
max-width: 100%; max-width: 100%;
@ -786,12 +1090,13 @@ body {
top: 76px; top: 76px;
} }
.tree-box { /* 树视图:和画布同一块高度,长了在自己内部滚 */
max-height: 620px; .graph-tree {
max-height: 560px;
overflow: auto; overflow: auto;
padding: 12px; padding: 12px 14px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 10px; border-radius: 14px;
background: var(--surface); background: var(--surface);
} }
@ -893,11 +1198,11 @@ body {
color: inherit; color: inherit;
} }
/* 选中项:白底小块浮在面板上,底色与右侧内容一致 */ /* 选中项:与其他地方的「选中」同一套语义(浅品牌底 + 品牌描边) */
.resource-tabs .ant-tabs-tab-active, .resource-tabs .ant-tabs-tab-active,
.resource-tabs .ant-tabs-tab-active:hover { .resource-tabs .ant-tabs-tab-active:hover {
background: var(--surface); background: var(--select-bg);
box-shadow: 0 1px 3px rgba(15, 17, 45, 0.14); box-shadow: inset 0 0 0 1px var(--select-border);
} }
.resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn, .resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
@ -1258,9 +1563,9 @@ body {
} }
.daily-rate--active { .daily-rate--active {
background: var(--brand-soft); background: var(--select-bg);
border-color: var(--brand); border-color: var(--select-border);
box-shadow: inset 0 0 0 1px var(--brand); box-shadow: inset 0 0 0 1px var(--select-border);
} }
.daily-rate-icon { .daily-rate-icon {
@ -1510,8 +1815,8 @@ body.split-dragging {
} }
.master-item--active { .master-item--active {
background: var(--brand-soft); background: var(--select-bg);
border-color: rgba(91, 92, 226, 0.35); border-color: var(--select-border);
} }
.master-item-title { .master-item-title {
@ -1640,8 +1945,9 @@ body.split-dragging {
} }
.paper-question--active { .paper-question--active {
background: var(--surface-2); background: var(--select-bg);
box-shadow: inset 3px 0 0 var(--brand); /* 整圈细描边取代原来的左侧指示条:卡片感更强,也不偏不倚 */
box-shadow: inset 0 0 0 1px var(--select-border);
} }
.paper-question-rail { .paper-question-rail {
@ -1765,8 +2071,8 @@ body.split-dragging {
} }
.answer-chip--active { .answer-chip--active {
border-color: var(--brand); border-color: var(--select-border);
box-shadow: 0 0 0 2px var(--brand-soft); box-shadow: var(--select-glow);
} }
.answer-chip-icon { .answer-chip-icon {
@ -1786,7 +2092,8 @@ body.split-dragging {
} }
.answer-chip--done { .answer-chip--done {
border-color: rgba(91, 92, 226, 0.45); background: var(--select-bg);
border-color: var(--select-border);
color: var(--brand); color: var(--brand);
} }
@ -1795,59 +2102,62 @@ body.split-dragging {
color: var(--text-muted); color: var(--text-muted);
} }
.answer-sheet-legend { .answer-sheet-pager {
display: flex; display: flex;
align-items: center; 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; gap: 10px;
font-size: 12px; padding: 10px 12px;
color: var(--text-secondary); 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; border: 0;
background: transparent;
padding: 0; 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; 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 { .answer-sheet-summary {
@ -1913,30 +2223,135 @@ body.split-dragging {
} }
/* 清单里的可点分组(教材名)与更紧凑的子项(章节) */ /* 清单里的可点分组(教材名)与更紧凑的子项(章节) */
.master-group-button { .master-tree-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: space-between;
width: 100%; gap: 8px;
padding: 5px 8px; margin: 4px 0 6px;
border: 0; padding: 0 8px;
border-radius: 8px; font-size: 12px;
background: transparent; font-weight: 600;
color: var(--text-strong); color: var(--text-muted);
font: inherit;
text-align: start;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
} }
.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); 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); 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 { .master-item--tight {
padding: 7px 10px; padding: 7px 10px;
margin-bottom: 2px; margin-bottom: 2px;
@ -2032,6 +2447,10 @@ body.split-dragging {
max-width: 100%; max-width: 100%;
} }
.test-launcher-count {
width: 118px;
}
.test-launcher-meta { .test-launcher-meta {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -279,6 +279,15 @@ export interface QuestionBankItem {
knowledge_ids: number[]; knowledge_ids: number[];
} }
/** 题库侧栏计数:全库真实题量,与按章节累加的 question_count 不同。 */
export interface QuestionBankSummary {
total: number;
unattached: number;
generated: number;
used: number;
}
export interface QuestionUpdatePayload { export interface QuestionUpdatePayload {
stem: string; stem: string;
options: string[]; options: string[];
@ -359,6 +368,25 @@ export interface KnowledgeGraphData {
edges: KnowledgeGraphEdge[]; 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 { export interface KnowledgeNodeCreatePayload {
name: string; name: string;
domain: string; domain: string;

View File

@ -1,33 +1,112 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
mkdir -p data mkdir -p data
if [[ ! -x backend/.venv/bin/python ]]; then WITH_WEB=1
if command -v uv >/dev/null 2>&1; then for arg in "$@"; do
echo "[init] 使用 uv 创建 Python 虚拟环境…" case "$arg" in
(cd backend && uv venv .venv --python 3.12) --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 else
if python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then (cd backend && .venv/bin/pip install ${1:+-i "$1"} -r requirements.txt)
echo "[init] 使用 python3 创建虚拟环境…" fi
python3 -m venv backend/.venv }
else
echo "[init] 未找到 Python 3.10+,请安装 uv(https://docs.astral.sh/uv/)后重试。" >&2 if ! install_backend "${PIP_INDEX_URL:-}"; then
exit 1 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 fi
fi fi
if command -v uv >/dev/null 2>&1; then echo
uv pip install --python backend/.venv/bin/python -r backend/requirements.txt echo "[init] 环境就绪:"
else echo " Python $(backend/.venv/bin/python -V 2>&1) → backend/.venv"
backend/.venv/bin/pip install -r backend/requirements.txt echo " Node $(node -v 2>/dev/null || echo '未安装')"
fi echo " 数据库 $ROOT_DIR/data/math.db"
echo "[init] 启动:./scripts/start.sh → http://localhost:5173"
(cd backend && .venv/bin/python -m seed)
echo "[init] 完成。启动方式:./scripts/start.sh"

View File

@ -1,8 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 同时启动后端(FastAPI :8000)与前端(Vite :5173)。 # 同时启动后端(FastAPI :8000)与前端(Vite :5173)。
# 端口可用 BACKEND_PORT / FRONTEND_PORT 覆盖。
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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 if [[ ! -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]]; then
echo "尚未初始化,请先运行 ./scripts/init.sh" >&2 echo "尚未初始化,请先运行 ./scripts/init.sh" >&2
@ -12,6 +15,24 @@ if [[ ! -f "$ROOT_DIR/data/math.db" ]]; then
echo "数据库不存在,请先运行 ./scripts/init.sh" >&2 echo "数据库不存在,请先运行 ./scripts/init.sh" >&2
exit 1 exit 1
fi 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 if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
echo "[start] 安装前端依赖…" echo "[start] 安装前端依赖…"
@ -26,11 +47,11 @@ cleanup() {
} }
trap cleanup EXIT INT TERM 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 \ (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=$! BACKEND_PID=$!
echo "[start] 前端:http://localhost:${FRONTEND_PORT:-5173}" echo "[start] 前端:http://localhost:${FRONTEND_PORT}"
(cd "$ROOT_DIR/frontend" && exec npm run dev -- \ (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}")