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
# 例:sqlite:////Users/you/nex_math/data/math.db
DATABASE_URL=
@ -5,8 +11,8 @@ DATABASE_URL=
# 允许跨域访问的前端地址,逗号分隔
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置)
# 可选:仅在初始化(python -m seed)建默认模型通道时使用
OPENAI_API_KEY=
# JWT 签名密钥(生产环境务必改成随机长字符串)
# JWT 签名密钥(生产环境务必改成随机长字符串;改动后所有人需重新登录)
JWT_SECRET=

View File

@ -182,9 +182,11 @@ docker compose up --build
**有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。
**注意**:补章节只发生在初始化(`python -m seed`)时;后端每次启动只对齐
表结构,不会再补数据,所以把自己准备好的 `math.db` 放回去后重启即原样生效。
**没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。
管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式:
@ -286,14 +288,21 @@ docker compose up --build
## 配置
复制 `.env.example` 并按需修改(本机直接运行时使用环境变量):
后端启动时(`backend/database.py` 导入阶段)会读取 `.env`,优先级为
**进程环境变量 > 仓库根 `.env` > `backend/.env` > 代码默认值**;
容器里 `docker-compose.yml` 注入的 `DATABASE_URL` 因此依然优先。
本机已有一份可用的 `.env`,换机器时复制 `.env.example` 按需修改即可。
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`;本机 `.env` 里写的是
绝对路径,直接就能看到当前实际连的库
- `CORS_ORIGINS`:允许访问的前端地址
- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建
“OpenAI · gpt-4o-mini”默认通道并使用该 Key;
- `OPENAI_API_KEY`:可选。只在初始化(`python -m seed`)时用来创建
“OpenAI · gpt-4o-mini”默认通道;正常使用的模型通道请在「模型配置」里维护,
`ai_teacher.py` 的逐题讲解仍为本地规则实现
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置(改后所有人需要重新登录)
**换库**:停掉后端 → 覆盖 `data/math.db` → 重启。启动只会建表 / 补列,
不会写入任何业务数据,覆盖回来的库保持原样。
## 重置演示数据
@ -335,5 +344,6 @@ cd backend
或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。
* 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书
(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
若库丢了,初始化时的 `seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的
已知电子书(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
(这一步只在 `python -m seed` 时执行,后端启动不再改动库与电子书文件。)

View File

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

View File

@ -1,15 +1,13 @@
"""数学学习系统后端入口。"""
from __future__ import annotations
import json
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from database import REPO_ROOT, SessionLocal, ensure_schema
from database import REPO_ROOT, ensure_schema
from routers import (
analytics,
archive,
@ -25,71 +23,13 @@ from routers import (
question_bank,
users,
)
from seed.catalog import ensure_catalog
from services import ebooks as ebook_store
from seed.ebooks import ensure_ebooks
from seed.knowledge_graph import ensure_knowledge_graph
from seed.rbac import ensure_rbac
from services.learning_profile import (
ensure_default_profiles,
remove_unused_admin_profile,
user_has_admin_role,
)
from services.llm_config import ensure_llm_channels
from models import AttemptItem, ErrorEntry, Question, User
from services.knowledge_service import set_question_knowledge_names
# 启动只对齐表结构(建表 / 补列),不写任何业务数据:
# 换成自己准备好的 math.db 后,重启即原样使用,不会被种子改动。
# 种子数据与历史数据迁移只在初始化时执行:
# cd backend && .venv/bin/python -m seed [--force]
ensure_schema()
db = SessionLocal()
try:
ensure_rbac(db)
ensure_knowledge_graph(db)
ensure_default_profiles(db)
for user in db.query(User).all():
if user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id)
ensure_catalog(db)
ensure_ebooks(db)
ebook_store.backfill_pages(db)
ensure_llm_channels(db)
db.execute(
text(
"UPDATE questions SET used_count = ("
"SELECT COUNT(*) FROM attempt_items "
"WHERE attempt_items.question_id = questions.id"
") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items "
"WHERE question_id IS NOT NULL)"
)
)
for question in db.query(Question).all():
has_links = db.execute(
text(
"SELECT 1 FROM question_knowledge "
"WHERE question_id = :qid LIMIT 1"
),
{"qid": question.id},
).first()
if has_links is None:
set_question_knowledge_names(
db,
question,
[question.knowledge_name],
attach_to_chapter=True,
)
for row in db.query(ErrorEntry).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
for row in db.query(AttemptItem).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
db.commit()
finally:
db.close()
def _cors_origins() -> list[str]:

View File

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

View File

@ -7,7 +7,7 @@ from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from database import SessionLocal, get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
@ -113,20 +113,8 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict:
)
if completion and completion.chapter_id is not None:
chapter = db.get(Chapter, completion.chapter_id)
textbook = (
db.get(Textbook, chapter.textbook_id) if chapter is not None else None
)
return {
"chapter_id": chapter.id if chapter else None,
"chapter_name": chapter.name if chapter else "",
"book_name": textbook.name if textbook else "",
"rating": completion.rating,
"knowledge_names": chapter_knowledge_names(db, chapter.id)
if chapter
else [],
"reason": "今日章节已完成",
**_ebook_ref(db, chapter.id if chapter else None),
}
if chapter is not None:
return _completed_context(db, chapter, completion.rating)
recommendation = build_daily_recommendation(db, user_id) or {}
return {
"chapter_id": recommendation.get("chapter_id"),
@ -139,6 +127,25 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict:
}
def _completed_context(db: Session, chapter: Chapter, rating: str) -> dict:
"""今日已完成时的上下文。
直接把刚写完的章节与自评拼出来,不再依赖「写完立刻回读」——
复用同一个连接时 SQLite 偶尔还会停在写入前的快照上,
响应里就会出现「已完成但自评是空的」这种自相矛盾的结果。
"""
textbook = db.get(Textbook, chapter.textbook_id)
return {
"chapter_id": chapter.id,
"chapter_name": chapter.name,
"book_name": textbook.name if textbook else "",
"rating": rating,
"knowledge_names": chapter_knowledge_names(db, chapter.id),
"reason": "今日章节已完成",
**_ebook_ref(db, chapter.id),
}
def _streak(done_dates: set[date]) -> int:
today = date.today()
if today not in done_dates and today - timedelta(days=1) not in done_dates:
@ -413,15 +420,23 @@ def complete_daily(
exists.chapter_id = chapter.id
exists.rating = payload.rating
db.commit()
# 写完用新会话回读连续天数:同一个 session 复用连接时可能读到写入前的快照,
# 会让响应里的连续天数与自评对不上(前端就会显示成提交没生效)。
read_db = SessionLocal()
try:
done_dates = {
row.done_on
for row in db.query(DailyCompletion)
for row in read_db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.all()
}
finally:
read_db.close()
# 今天这一条就是本次写入的,不依赖回读结果
done_dates.add(today)
return DailyOut(
today=today,
completed=True,
streak=_streak(done_dates),
**_daily_context(db, user.id, today),
**_completed_context(db, chapter, payload.rating),
)

View File

@ -6,7 +6,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_permission
from dependencies import require_learning_user, require_permission
from models import (
ChapterKnowledge,
Course,
@ -22,12 +22,15 @@ from schemas import (
KnowledgeGraphEdge,
KnowledgeGraphNode,
KnowledgeGraphOut,
KnowledgeMapNode,
KnowledgeMapOut,
KnowledgeNodeCreate,
KnowledgeNodeUpdate,
KnowledgeRelationIn,
KnowledgeResourceIn,
KnowledgeResourceOut,
)
from services.mastery_engine import status_for
from services.knowledge_service import knowledge_resources
@ -98,6 +101,69 @@ def get_graph(
)
@router.get("/map", response_model=KnowledgeMapOut)
def get_knowledge_map(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
"""学生视角的完整知识图谱:全量结构 + 本人掌握度。
/knowledge 只返回练过的知识点,画不出一张完整的地图;
这里一次给全量节点(含未练过的)与全部关系,计数用分组查询,避免逐点 N+1。
"""
nodes = (
db.query(Knowledge)
.order_by(Knowledge.domain, Knowledge.category, Knowledge.id)
.all()
)
edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all()
mastery = {
row.knowledge_id: row.mastery
for row in db.query(UserKnowledge)
.filter(UserKnowledge.user_id == user.id)
.all()
}
question_counts = dict(
db.query(QuestionKnowledge.knowledge_id, func.count(QuestionKnowledge.id))
.group_by(QuestionKnowledge.knowledge_id)
.all()
)
chapter_counts = dict(
db.query(ChapterKnowledge.knowledge_id, func.count(ChapterKnowledge.id))
.group_by(ChapterKnowledge.knowledge_id)
.all()
)
def count(mapping: dict, node_id: int) -> int:
return int(mapping.get(node_id, 0) or 0)
return KnowledgeMapOut(
nodes=[
KnowledgeMapNode(
id=node.id,
name=node.name,
domain=node.domain,
category=node.category,
description=node.description,
mastery=mastery.get(node.id, 0),
status=status_for(mastery.get(node.id, 0)),
practiced=node.id in mastery,
question_count=count(question_counts, node.id),
chapter_count=count(chapter_counts, node.id),
)
for node in nodes
],
edges=[
KnowledgeGraphEdge(
source=edge.source_id,
target=edge.target_id,
relation_type=edge.relation_type,
)
for edge in edges
],
)
@router.post("/nodes", response_model=KnowledgeGraphNode)
def create_node(
payload: KnowledgeNodeCreate,

View File

@ -5,6 +5,7 @@ import json
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
@ -19,7 +20,12 @@ from models import (
Textbook,
User,
)
from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate
from schemas import (
BankSummaryOut,
GenerateIn,
QuestionBankItemOut,
QuestionUpdate,
)
from services.question_generator import generate_questions
from services.llm_config import get_default_channel
from services.knowledge_service import (
@ -141,6 +147,27 @@ def list_questions(
return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows]
@router.get("/summary", response_model=BankSummaryOut)
def summary(
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
"题库侧栏计数:总数、未挂章节、AI 生成、已被使用,一次算完。"
def count(*conditions) -> int:
query = db.query(func.count(Question.id))
for condition in conditions:
query = query.filter(condition)
return int(query.scalar() or 0)
return BankSummaryOut(
total=count(),
unattached=count(Question.chapter_id.is_(None)),
generated=count(Question.is_generated.is_(True)),
used=count(Question.used_count > 0),
)
@router.delete("/questions/{question_id}")
def delete_question(
question_id: int,

View File

@ -303,6 +303,14 @@ class QuestionBankItemOut(BaseModel):
knowledge_ids: list[int] = []
class BankSummaryOut(BaseModel):
"侧栏计数用:全库题目总数与几个切片,避免把整库题目拉回前端数数。"
total: int
unattached: int
generated: int
used: int
class QuestionUpdate(BaseModel):
stem: str = Field(min_length=1)
options: list[str] = Field(min_length=4, max_length=4)
@ -541,6 +549,26 @@ class KnowledgeGraphOut(BaseModel):
edges: list[KnowledgeGraphEdge] = []
class KnowledgeMapNode(BaseModel):
"""学生视角的知识节点:结构 + 本人掌握度,一次给全量。"""
id: int
name: str
domain: str
category: str
description: str = ""
mastery: int = 0
status: str = "重点"
# 有没有练过:没练过的不该被当成「薄弱」,界面上单独表达
practiced: bool = False
chapter_count: int = 0
question_count: int = 0
class KnowledgeMapOut(BaseModel):
nodes: list[KnowledgeMapNode] = []
edges: list[KnowledgeGraphEdge] = []
class KnowledgeNodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
domain: str = "初等"

View File

@ -2,6 +2,10 @@
用法:在 backend/ 目录下执行 python -m seed [--force]
--force 会清空全部学习数据与题库后重新导入,保留用户与角色。
这里是**唯一**会补种数据的地方:后端每次启动只对齐表结构
(``database.ensure_schema``),不再补章节 / 知识点 / 教材等业务数据,
因此自己准备的 math.db 覆盖回来后不会被种子改动。
"""
from __future__ import annotations
@ -9,7 +13,14 @@ import argparse
import json
from datetime import date, timedelta
from database import DATABASE_URL, SessionLocal, ensure_schema
from sqlalchemy import text
from database import (
DATABASE_URL,
SessionLocal,
ensure_schema,
migrate_legacy_courses,
)
from models import (
AttemptItem,
AttemptSession,
@ -22,7 +33,9 @@ from models import (
from seed.catalog import ensure_catalog
from seed.data import KNOWLEDGE, QUESTIONS
from seed.ebooks import ensure_ebooks
from seed.knowledge_graph import ensure_knowledge_graph
from seed.rbac import DEFAULT_USERS, ensure_rbac
from services import ebooks as ebook_store
from services.learning_profile import (
ensure_default_profiles,
remove_unused_admin_profile,
@ -116,12 +129,58 @@ def _demo_history(db, student: User) -> None:
db.add(DailyCompletion(user_id=student.id, done_on=date.today() - timedelta(days=1)))
def _backfill_derived_fields(db) -> None:
"""给存量数据补派生字段(题目使用次数、知识点名快照)。
以前这些 UPDATE 挂在后端启动流程里,现在只属于初始化:
自己准备好的库覆盖回来后不会被重新计算 / 覆盖。
"""
db.execute(
text(
"UPDATE questions SET used_count = ("
"SELECT COUNT(*) FROM attempt_items "
"WHERE attempt_items.question_id = questions.id"
") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items "
"WHERE question_id IS NOT NULL)"
)
)
for question in db.query(Question).all():
has_links = db.execute(
text(
"SELECT 1 FROM question_knowledge "
"WHERE question_id = :qid LIMIT 1"
),
{"qid": question.id},
).first()
if has_links is None:
set_question_knowledge_names(
db,
question,
[question.knowledge_name],
attach_to_chapter=True,
)
for row in db.query(ErrorEntry).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
for row in db.query(AttemptItem).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
db.commit()
def load_seed(force: bool = False) -> bool:
ensure_schema()
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
migrate_legacy_courses()
db = SessionLocal()
created = False
try:
ensure_rbac(db)
ensure_knowledge_graph(db)
users = {
username: db.query(User).filter(User.username == username).first()
for username, _, _, _ in DEFAULT_USERS
@ -164,6 +223,8 @@ def load_seed(force: bool = False) -> bool:
ensure_catalog(db, force_sync=force)
# 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好
ensure_ebooks(db)
ebook_store.backfill_pages(db)
_backfill_derived_fields(db)
for user in users.values():
if user and user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id)

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>数学学习系统</title>
</head>
<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":
return <Errors />;
case "records":
return <Records />;
return <Records onNavigate={navigate} />;
case "daily":
return (
<Daily

View File

@ -18,6 +18,7 @@ import type {
Overview,
PracticeSession,
QuestionBankItem,
QuestionBankSummary,
QuestionUpdatePayload,
RecordDetail,
RecordItem,
@ -36,6 +37,7 @@ import type {
KnowledgeGraphData,
KnowledgeGraphEdge,
KnowledgeGraphNode,
KnowledgeMapData,
KnowledgeResourceLink,
KnowledgeNodeCreatePayload,
KnowledgeNodeUpdatePayload,
@ -335,6 +337,8 @@ export const api = {
`/api/question-bank/questions${query ? `?${query}` : ""}`,
);
},
bankSummary: () =>
request<QuestionBankSummary>("/api/question-bank/summary"),
deleteQuestion: (id: number) =>
request<{ deleted: number }>(`/api/question-bank/questions/${id}`, {
method: "DELETE",
@ -355,6 +359,7 @@ export const api = {
request<LlmTaskResult>(`/api/llm/tasks/${id}`),
knowledgeGraph: () =>
request<KnowledgeGraphData>("/api/knowledge/graph"),
knowledgeMap: () => request<KnowledgeMapData>("/api/knowledge/map"),
createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) =>
request<KnowledgeGraphNode>("/api/knowledge/nodes", {
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 (
<Button
key={`${option}-${index}`}
type={selected ? "primary" : "default"}
block
className={`option-row${selected ? " option-row--active" : ""}`}
onClick={() => onChange(index)}
>
<span className="option-row-letter">{optionLetter(index)}.</span>
<RichText content={option} />
{selected && <CheckCircleFilled className="option-row-check" />}
</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 { AnswerCompare, AnswerSheet, PaperQuestion, scrollToQuestion } from "./Paper";
import { OptionList, optionLetter } from "./Options";
import { Pane, SplitView } from "./SplitView";
import RichText from "./RichText";
import { DataRow, DataRows, Panel, SectionTitle } from "./ui";
import { status, tagColor } from "../theme";
import { useT } from "../prefs";
import { masteryTagColor, tagColor } from "../theme";
interface PracticeResultProps {
session: PracticeSession;
@ -25,53 +29,164 @@ export function PracticeResult({
onExit,
}: PracticeResultProps) {
const t = useT();
const reviews = result.per_question.map((item) => ({
...item,
const bodyRef = useRef<HTMLDivElement | null>(null);
const questions = useMemo(
() =>
result.per_question.map((item, index) => ({
item,
index,
question: session.questions.find((q) => q.id === item.question_id),
}));
})),
[result.per_question, session.questions],
);
const wrong = questions.filter(({ item }) => !item.correct);
return (
<Panel>
<Alert
type={result.score === result.total ? "success" : "info"}
showIcon
title={t("result.score", { score: result.score, total: result.total })}
description={t("result.summary", { accuracy: result.accuracy })}
<SplitView
side="right"
storageKey="nex_math.split.test.aside"
defaultWidth={286}
aside={
<Pane
title={t("result.sheet")}
toolbar={
<AnswerSheet
items={questions.map(({ item, index }) => ({
key: index,
label: index + 1,
status: item.correct ? "correct" : "wrong",
}))}
onSelect={(key) => scrollToQuestion(bodyRef.current, key)}
size="small"
/>
<div style={{ marginTop: 20 }}>
<SectionTitle>{t("result.review")}</SectionTitle>
</div>
<DataRows>
{reviews.map((review, index) => (
<DataRow
key={`${review.question_id}-${index}`}
avatar={
review.correct ? (
<CheckCircleOutlined style={{ color: status.success, fontSize: 22 }} />
) : (
<CloseCircleOutlined style={{ color: status.error, fontSize: 22 }} />
)
}
title={
<Space wrap size={[4, 4]}>
{review.question && <RichText content={review.question.stem} />}
<Tag color={review.correct ? tagColor.positive : tagColor.negative}>
{review.correct ? t("result.correct") : t("result.wrong")}
</Tag>
</Space>
}
description={<RichText content={review.explanation} />}
/>
))}
</DataRows>
<Flex gap={10} style={{ marginTop: 16 }}>
<Button type="primary" icon={<ReloadOutlined />} onClick={onRestart}>
footer={
<Flex vertical gap={8}>
<Button
type="primary"
block
icon={<ReloadOutlined />}
onClick={onRestart}
>
{t("result.retry")}
</Button>
<Button icon={<DashboardOutlined />} onClick={onExit}>
<Button block icon={<DashboardOutlined />} onClick={onExit}>
{t("result.back")}
</Button>
</Flex>
</Panel>
}
>
<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 }}>
<Tag color={tagColor.positive}>
{t("result.correctN", { n: result.score })}
</Tag>
<Tag color={tagColor.negative}>
{t("result.wrongN", { n: wrong.length })}
</Tag>
</Flex>
{result.knowledge.length > 0 && (
<div className="result-knowledge">
<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 type { PracticeSession, SubmitResult } from "../types";
import { Panel, useUi } from "./ui";
import { Pane, SplitView } from "./SplitView";
import { AnswerSheet, PaperQuestion, scrollToQuestion, useActiveAnchor } from "./Paper";
import { OptionPicker } from "./Options";
import { PracticeResult } from "./PracticeResult";
import { EmptyState, useUi } from "./ui";
import RichText from "./RichText";
import { tagColor } from "../theme";
import { useT } from "../prefs";
interface QuestionFlowProps {
@ -22,7 +33,7 @@ export default function QuestionFlow({
}: QuestionFlowProps) {
const { message } = useUi();
const t = useT();
const [index, setIndex] = useState(0);
const bodyRef = useRef<HTMLDivElement | null>(null);
const [answers, setAnswers] = useState<(number | undefined)[]>(
session.questions.map(() => undefined),
);
@ -30,8 +41,18 @@ export default function QuestionFlow({
const [result, setResult] = useState<SubmitResult | null>(null);
const total = session.questions.length;
const question = session.questions[index];
const progress = total ? Math.round((index / total) * 100) : 0;
const empty = total === 0;
const answered = answers.filter((value) => value !== undefined).length;
const progress = total ? Math.round((answered / total) * 100) : 0;
const keys = useMemo(() => session.questions.map((_, i) => i), [session.questions]);
const active = useActiveAnchor(bodyRef, keys, answered);
/** 当前滚动到的题(答题卡上高亮的那一道),用来在侧栏显示“正在做第几题”。 */
const activeIndex = active === null ? 0 : Number(active);
const activeAnswer = answers[activeIndex];
const activeAnswered = activeAnswer !== undefined;
const unansweredIndexes = answers
.map((value, index) => (value === undefined ? index : -1))
.filter((index) => index >= 0);
if (result) {
return (
@ -44,28 +65,33 @@ export default function QuestionFlow({
);
}
function choose(optionIndex: number) {
function choose(questionIndex: number, optionIndex: number) {
setAnswers((prev) => {
const next = prev.slice();
next[index] = optionIndex;
next[questionIndex] = optionIndex;
return next;
});
}
function jumpTo(key: string | number) {
scrollToQuestion(bodyRef.current, key);
}
async function finish() {
if (answers.some((value) => value === undefined)) {
message.warning(t("flow.unfinished"));
if (empty) return;
const firstMissing = answers.findIndex((value) => value === undefined);
if (firstMissing >= 0) {
message.warning(t("flow.unfinishedAt", { index: firstMissing + 1 }));
jumpTo(firstMissing);
return;
}
setSubmitting(true);
try {
const payload = session.questions.map((q, i) => ({
question_id: q.id,
const payload = session.questions.map((question, i) => ({
question_id: question.id,
selected: answers[i] as number,
}));
setResult(
await api.submit(session.mode, payload, session.chapter_id),
);
setResult(await api.submit(session.mode, payload, session.chapter_id));
} catch (err) {
message.error(err instanceof Error ? err.message : t("flow.submitFailed"));
} finally {
@ -73,45 +99,151 @@ export default function QuestionFlow({
}
}
if (!question) return null;
const isLast = index === total - 1;
return (
<Panel>
<Flex align="center" justify="space-between" gap={12} style={{ marginBottom: 8 }}>
<Tag color={tagColor.knowledge}>{session.title}</Tag>
<Typography.Text type="secondary">
{t("flow.progressPrefix")} <b>{index + 1}</b> / {total}{" "}
{t("flow.progressSuffix")}
<SplitView
side="right"
storageKey="nex_math.split.test.aside"
defaultWidth={268}
aside={
<Pane
title={t("flow.sheet")}
extra={
!empty && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("flow.answered", { answered, total })}
</Typography.Text>
</Flex>
<Progress percent={progress} showInfo={false} />
<div className="question-stem">
<RichText content={question.stem} />
</div>
<OptionPicker
options={question.options}
value={answers[index]}
onChange={choose}
)
}
toolbar={
empty ? (
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
{t("flow.emptyPaperShort")}
</Typography.Text>
) : (
<AnswerSheet
items={session.questions.map((_, i) => ({
key: i,
label: i + 1,
status: answers[i] === undefined ? "pending" : "done",
hint:
answers[i] === undefined
? t("flow.unanswered")
: t("flow.answeredAt", {
index: i + 1,
letter: String.fromCharCode(65 + (answers[i] as number)),
}),
}))}
activeKey={active}
onSelect={jumpTo}
size="small"
/>
<Space style={{ marginTop: 20 }}>
<Button disabled={index === 0} onClick={() => setIndex((value) => value - 1)}>
{t("flow.prev")}
</Button>
{!isLast ? (
)
}
footer={
<Flex vertical gap={10}>
{!empty && (
<Progress
percent={progress}
showInfo={false}
status={progress === 100 ? "success" : "active"}
/>
)}
<Button
type="primary"
disabled={answers[index] === undefined}
onClick={() => setIndex((value) => value + 1)}
block
loading={submitting}
disabled={empty}
onClick={() => void finish()}
>
{t("flow.next")}
</Button>
) : (
<Button type="primary" loading={submitting} onClick={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>
)}
</Space>
</Panel>
</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 右侧,数量统计交给分页条,工具条不再承载按钮。
*/
export function Toolbar({ filters }: { filters?: ReactNode }) {
return <div className="panel-toolbar">{filters}</div>;
export function Toolbar({
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;
}
/**
* 取数钩子。`enabled: false` 时完全不发请求,也不置 loading,
* 让页面自己渲染引导空态——缺少前置条件(未选章节、未选教材)时
* 不该把「还没开始」渲染成红色的「加载失败」。
*/
export function useApi<T>(
load: () => Promise<T>,
deps: unknown[] = [],
options?: { enabled?: boolean },
): ApiState<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const loaderRef = useRef(load);
loaderRef.current = load;
const enabled = options?.enabled ?? true;
useEffect(() => {
if (!enabled) {
setData(null);
setError(null);
return;
}
let alive = true;
setData(null);
setError(null);
@ -32,12 +44,12 @@ export function useApi<T>(
return () => {
alive = false;
};
}, [version, ...deps]);
}, [version, enabled, ...deps]);
return {
data,
error,
loading: data === null && error === null,
loading: enabled && data === null && error === null,
reload: () => setVersion((v) => v + 1),
};
}

View File

@ -295,9 +295,12 @@ const zh = {
"daily.nextStep": "下一步",
"daily.retest": "再做本章测试",
"daily.openMap": "查看知识地图",
"chapterTest.selectChapterRequired": "请选择章节",
"chapterTest.emptyNoTextbook": "还没有教材,暂时无法按章节组卷。请管理员在「教材管理」中添加教材并整理章节目录。",
"chapterTest.emptyNoChapters": "{name} 还没有章节目录。请管理员在「教材管理」里添加章节,并在「题库管理」把题目挂到章节下,之后就能按章节组卷练习。",
"chapterTest.emptyPickChapter": "在上方选一章,系统会按你的薄弱知识点组一张卷子。",
"chapterTest.title": "选择测试章节",
"chapterTest.reshuffle": "换一套",
"chapterTest.countOption": "{n} 题",
"chapterTest.textbookPlaceholder": "选择教材",
"chapterTest.chapterPlaceholder": "选择章节",
"chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成",
@ -323,8 +326,10 @@ const zh = {
"knowledge.relPrereqOut": "后续知识点",
"knowledge.relRelated": "相关知识点",
"knowledge.masteryTitle": "知识点掌握度",
"knowledge.averageMastery": "平均掌握度",
"knowledge.practicedCount": "已练知识点",
"knowledge.focusCount": "待重点强化",
"knowledge.bandAll": "全部",
"knowledge.emptyBand": "该档暂无知识点",
"knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节",
"knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度",
"knowledge.detailTitle": "知识点详情",
@ -385,13 +390,27 @@ const zh = {
"llm.willSave": "将保存为:{provider} · {model}",
"graph.nodeTitle": "{name}(章 {chapters} · 题 {questions})",
"graph.domainAll": "全部领域",
"graph.searchPlaceholder": "搜索知识点",
"graph.canvasHint": "点知识点看关联;缩放/拖动可调整视图",
"graph.zoomIn": "放大",
"graph.zoomOut": "缩小",
"graph.zoomReset": "还原视图",
"graph.emptyDomain": "该领域还没有知识点",
"graph.notPracticed": "未练习",
"graph.masteryValue": "掌握度 {n}%",
"graph.nodeCounts": "题 {questions} · 章 {chapters}",
"graph.columnQuestions": "{n} 道题",
"graph.edgeLegend": "实线箭头 = 前置(先学箭头尾部的点),虚线 = 相关;点知识点高亮一跳关联",
"graph.domainElementary": "初等数学",
"graph.domainAdvanced": "高等数学",
"graph.domainPlaceholder": "选择知识领域",
"graph.allCategories": "全部(完整图谱)",
"graph.add": "新增知识点",
"graph.addRelation": "添加关系",
"graph.detailTitle": "{name} · 详情",
"graph.detailTitleEmpty": "知识点详情",
"graph.detailEmpty": "在上方图谱或树里点一个知识点,这里显示它的定义、关系、关联章节与课程。",
"graph.viewGraph": "图谱",
"graph.viewTree": "树形",
"graph.fieldDomain": "领域",
"graph.fieldCategory": "分类",
"graph.fieldUsage": "引用",
@ -433,10 +452,15 @@ const zh = {
"graph.msgUnlinkFailed": "解除失败",
"bank.colQuestion": "题目",
"bank.colSource": "来源 / 章节",
"bank.generated": "AI 生成",
"bank.builtin": "内置题目",
"bank.colChapter": "章节",
"bank.chapterTree": "教材 / 章节",
"bank.expandAll": "展开全部",
"bank.collapseAll": "收起全部",
"bank.expandBook": "展开章节",
"bank.collapseBook": "收起章节",
"bank.noChaptersYet": "暂无章节",
"bank.noChapter": "未挂章节",
"bank.difficultyAll": "难度不限",
"bank.colDifficulty": "难度",
"bank.colUsage": "使用状态",
"bank.usedN": "已使用 {n} 次",
@ -720,6 +744,72 @@ const zh = {
"api.taskKindUnsupported": "不支持的任务类型:{kind}",
"api.knowledgeSomeMissing": "部分知识点不存在",
"api.ebookFileInvalid": "{name} 不是有效的 {format} 文件",
// —— 分屏试卷工作台(题库 / 记录 / 错题 / 答题共用)——
"paper.prevPage": "上一页",
"paper.nextPage": "下一页",
"paper.pageRange": "第 {start}–{end} 题 / 共 {total} 题",
"paper.unanswered": "未作答",
"paper.yourAnswer": "我的作答:{answer}",
"paper.yourAnswerCorrect": "我的作答:{answer}(正确)",
"paper.correctAnswer": "正确答案:{answer}",
"paper.score": "得分",
"paper.filterAll": "全部 {n} 题",
"paper.filterWrong": "错题 {n} 题",
"paper.expandExplanations": "展开全部解析",
"paper.showExplanation": "查看解析",
"paper.emptyPaper": "这份试卷里还没有题目",
"records.asideTitle": "练习记录",
"records.search": "搜索练习名称或日期",
"records.stat": "{sessions} 次 · 总正确率 {accuracy}%",
"records.summary": "{correct} / {total} 题正确",
"records.metaLine": "{date} · 共 {total} 题 · 答对 {correct} 题 · 正确率 {accuracy}%",
"records.pickOne": "从左侧选择一次练习,查看逐题明细",
"records.startTest": "去做一次章节测试",
"records.needsReview": "建议回看",
"errors.asideTitle": "错题清单",
"errors.search": "搜索题干或知识点",
"errors.filterKnowledge": "按知识点筛选",
"errors.stat": "共 {n} 题",
"errors.detailTitle": "错题精讲",
"errors.metaLine": "来源:{source} · 记录于 {date} · 诊断:{diagnosis}",
"errors.position": "第 {index} / {total} 题",
"errors.prev": "上一题",
"errors.next": "下一题",
"bank.asideTitle": "教材与章节",
"bank.navAll": "全部题目",
"bank.unattachedHint": "其中 {n} 题还没挂到章节",
"bank.search": "搜索题干、选项或知识点",
"bank.metaLine": "共 {total} 题 · 已使用 {used} 题",
"bank.noMatch": "当前筛选条件下没有题目,换个章节或清除筛选",
"bank.viewCard": "试卷视图",
"bank.viewTable": "表格视图",
"bank.usageAll": "使用不限",
"bank.filterUsed": "已使用",
"bank.filterUnused": "未使用",
"bank.difficultyValue": "{stars}",
"flow.sheet": "答题卡",
"flow.answered": "已答 {answered} / {total}",
"flow.currentNo": "当前第 {index} 题",
"flow.currentPicked": "已选 {letter}",
"flow.jumpToUnanswered": "跳到第 {index} 题(未作答)",
"flow.answeredAt": "第 {index} 题已选 {letter}",
"flow.unanswered": "未作答",
"flow.answeredTag": "已作答",
"flow.metaLine": "共 {total} 题 · 已作答 {answered} 题",
"flow.unfinishedAt": "第 {index} 题还没有作答,请完成后提交",
"flow.pendingN": "还有 {n} 题未作答",
"flow.readyToSubmit": "全部题目已作答,可以提交了",
"flow.emptyPaper": "这一章还没有题目,暂时无法作答。请管理员在「题库管理」里添加题目并关联到本章。",
"flow.emptyPaperShort": "本章暂无题目,无法提交",
"result.sheet": "成绩单",
"result.correctN": "答对 {n} 题",
"result.wrongN": "答错 {n} 题",
"result.mastery": "知识点掌握度",
} as const;
export type MsgKey = keyof typeof zh;
@ -1009,9 +1099,12 @@ const en: Record<MsgKey, string> = {
"daily.nextStep": "Next",
"daily.retest": "Take this chapter's test again",
"daily.openMap": "Open knowledge map",
"chapterTest.selectChapterRequired": "Please select a chapter",
"chapterTest.emptyNoTextbook": "No textbooks yet, so there is nothing to build a paper from. Ask an administrator to add a textbook and set up its chapters under Textbooks.",
"chapterTest.emptyNoChapters": "{name} has no chapter list yet. Ask an administrator to add chapters under Textbooks and link questions to them in Question Bank, then you can build a paper by chapter.",
"chapterTest.emptyPickChapter": "Pick a chapter above and we will build a paper around your weak knowledge points.",
"chapterTest.title": "Choose a chapter to test",
"chapterTest.reshuffle": "Reshuffle",
"chapterTest.countOption": "{n} questions",
"chapterTest.textbookPlaceholder": "Select textbook",
"chapterTest.chapterPlaceholder": "Select chapter",
"chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank",
@ -1036,8 +1129,10 @@ const en: Record<MsgKey, string> = {
"knowledge.relPrereqOut": "Follow-up points",
"knowledge.relRelated": "Related knowledge points",
"knowledge.masteryTitle": "Mastery by knowledge point",
"knowledge.averageMastery": "Average mastery",
"knowledge.practicedCount": "Practiced points",
"knowledge.focusCount": "Needs focus",
"knowledge.bandAll": "All",
"knowledge.emptyBand": "No knowledge points in this band",
"knowledge.hint": "Click any knowledge point for its definition, links and textbook chapters",
"knowledge.empty": "No mastery data yet — finish a chapter test and it will be tracked on the knowledge graph",
"knowledge.detailTitle": "Knowledge point",
@ -1097,13 +1192,27 @@ const en: Record<MsgKey, string> = {
"llm.autoPlaceholder": "Auto if blank",
"llm.willSave": "Will be saved as: {provider} · {model}",
"graph.nodeTitle": "{name} (ch {chapters} · q {questions})",
"graph.domainAll": "All domains",
"graph.searchPlaceholder": "Search knowledge points",
"graph.canvasHint": "Click a point to see its links; zoom or drag to adjust the view",
"graph.zoomIn": "Zoom in",
"graph.zoomOut": "Zoom out",
"graph.zoomReset": "Reset view",
"graph.emptyDomain": "No knowledge points in this domain yet",
"graph.notPracticed": "Not practiced",
"graph.masteryValue": "Mastery {n}%",
"graph.nodeCounts": "{questions} questions · {chapters} chapters",
"graph.columnQuestions": "{n} questions",
"graph.edgeLegend": "Solid arrow = prerequisite (study the tail first); dashed = related. Click a point to highlight its direct links",
"graph.domainElementary": "Elementary mathematics",
"graph.domainAdvanced": "Advanced mathematics",
"graph.domainPlaceholder": "Select a domain",
"graph.allCategories": "All (full graph)",
"graph.add": "New knowledge point",
"graph.addRelation": "Add relation",
"graph.detailTitle": "{name} · Details",
"graph.detailTitleEmpty": "Knowledge point",
"graph.detailEmpty": "Pick a knowledge point in the graph or tree above to see its definition, relations, chapters and courses here.",
"graph.viewGraph": "Graph",
"graph.viewTree": "Tree",
"graph.fieldDomain": "Domain",
"graph.fieldCategory": "Category",
"graph.fieldUsage": "Used by",
@ -1144,10 +1253,15 @@ const en: Record<MsgKey, string> = {
"graph.msgUnlinked": "Link removed",
"graph.msgUnlinkFailed": "Unlink failed",
"bank.colQuestion": "Question",
"bank.colSource": "Source / Chapter",
"bank.generated": "AI-generated",
"bank.builtin": "Built-in",
"bank.colChapter": "Chapter",
"bank.chapterTree": "Textbooks / chapters",
"bank.expandAll": "Expand all",
"bank.collapseAll": "Collapse all",
"bank.expandBook": "Expand chapters",
"bank.collapseBook": "Collapse chapters",
"bank.noChaptersYet": "No chapters yet",
"bank.noChapter": "No chapter",
"bank.difficultyAll": "Any level",
"bank.colDifficulty": "Difficulty",
"bank.colUsage": "Usage",
"bank.usedN": "Used {n} times",
@ -1424,6 +1538,72 @@ const en: Record<MsgKey, string> = {
"api.taskKindUnsupported": "Unsupported task type: {kind}",
"api.knowledgeSomeMissing": "Some knowledge points do not exist",
"api.ebookFileInvalid": "{name} is not a valid {format} file",
// —— Split paper workbench (shared by bank / records / errors / test) ——
"paper.prevPage": "Previous page",
"paper.nextPage": "Next page",
"paper.pageRange": "{start}–{end} of {total} questions",
"paper.unanswered": "Not answered",
"paper.yourAnswer": "Your answer: {answer}",
"paper.yourAnswerCorrect": "Your answer: {answer} (correct)",
"paper.correctAnswer": "Correct answer: {answer}",
"paper.score": "Score",
"paper.filterAll": "All {n}",
"paper.filterWrong": "Wrong {n}",
"paper.expandExplanations": "Expand all explanations",
"paper.showExplanation": "Show explanation",
"paper.emptyPaper": "This paper has no questions yet",
"records.asideTitle": "Practice log",
"records.search": "Search session or date",
"records.stat": "{sessions} sessions · {accuracy}% overall",
"records.summary": "{correct} / {total} correct",
"records.metaLine": "{date} · {total} questions · {correct} correct · {accuracy}% accuracy",
"records.pickOne": "Pick a session on the left to review it question by question",
"records.startTest": "Take a chapter test",
"records.needsReview": "Worth a review",
"errors.asideTitle": "Mistake list",
"errors.search": "Search stem or knowledge point",
"errors.filterKnowledge": "Filter by knowledge",
"errors.stat": "{n} questions",
"errors.detailTitle": "Mistake walkthrough",
"errors.metaLine": "Source: {source} · logged {date} · diagnosis: {diagnosis}",
"errors.position": "Question {index} of {total}",
"errors.prev": "Previous",
"errors.next": "Next",
"bank.asideTitle": "Textbooks & chapters",
"bank.navAll": "All questions",
"bank.unattachedHint": "{n} of them are not linked to a chapter yet",
"bank.search": "Search stem, options or knowledge point",
"bank.metaLine": "{total} questions · {used} used",
"bank.noMatch": "No question matches the current filters. Try another chapter or clear them",
"bank.viewCard": "Paper view",
"bank.viewTable": "Table view",
"bank.usageAll": "Any usage",
"bank.filterUsed": "Used",
"bank.filterUnused": "Unused",
"bank.difficultyValue": "{stars}",
"flow.sheet": "Answer sheet",
"flow.answered": "{answered} / {total} answered",
"flow.currentNo": "Question {index}",
"flow.currentPicked": "Picked {letter}",
"flow.jumpToUnanswered": "Jump to question {index} (unanswered)",
"flow.answeredAt": "Question {index}: {letter}",
"flow.unanswered": "Unanswered",
"flow.answeredTag": "Answered",
"flow.metaLine": "{total} questions · {answered} answered",
"flow.unfinishedAt": "Question {index} is still unanswered. Finish it before submitting",
"flow.pendingN": "{n} question(s) left",
"flow.readyToSubmit": "All questions answered — ready to submit",
"flow.emptyPaper": "This chapter has no questions yet, so there is nothing to answer. Ask an administrator to add questions in Question Bank and link them to this chapter.",
"flow.emptyPaperShort": "No questions in this chapter yet",
"result.sheet": "Report card",
"result.correctN": "{n} correct",
"result.wrongN": "{n} wrong",
"result.mastery": "Knowledge mastery",
};
let currentLang: Lang = "zh";

View File

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

View File

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

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 { AsyncState } from "../components/Async";
import { QuestionDetail } from "../components/QuestionDetail";
import { AnswerCompare, PaperQuestion } from "../components/Paper";
import { OptionList, optionLetter } from "../components/Options";
import { Pane, SplitView } from "../components/SplitView";
import { PaperMetaText } from "../components/PaperReview";
import RichText from "../components/RichText";
import { useApi } from "../components/useApi";
import {
DataTable,
EmptyState,
KnowledgeTags,
Panel,
SubText,
} from "../components/ui";
import { EmptyState, KnowledgeTags } from "../components/ui";
import { backendText, enumLabel } from "../i18n";
import { useT } from "../prefs";
import { tagColor } from "../theme";
import type { ErrorEntry } from "../types";
function knowledgeOf(entry: ErrorEntry): string[] {
const names = (entry.knowledge_names ?? []).length
? (entry.knowledge_names as string[])
: [entry.knowledge_name];
return names.filter(Boolean);
}
export default function Errors() {
const t = useT();
const { data, loading, error, reload } = useApi(api.errors);
const entries = useMemo(() => data ?? [], [data]);
const [keyword, setKeyword] = useState("");
const [knowledge, setKnowledge] = useState<string | undefined>(undefined);
const [selectedId, setSelectedId] = useState<number | null>(null);
const columns: ColumnsType<ErrorEntry> = [
{
title: t("errors.colRound"),
dataIndex: "source_label",
width: 170,
render: (value: string) => <b>{value}</b>,
},
{
title: t("errors.colQuestion"),
key: "stem",
render: (_, entry) =>
entry.stem ? (
<RichText content={entry.stem} compact />
) : (
<SubText as="span">{t("errors.legacyNoDetail")}</SubText>
),
},
{
title: t("errors.colKnowledge"),
key: "knowledge",
render: (_, entry) => (
<KnowledgeTags
names={
(entry.knowledge_names ?? []).length > 0
? entry.knowledge_names
: [entry.knowledge_name]
const knowledgeOptions = useMemo(() => {
const names = new Set<string>();
entries.forEach((entry) => knowledgeOf(entry).forEach((name) => names.add(name)));
return Array.from(names)
.sort()
.map((name) => ({ value: name, label: name }));
}, [entries]);
const visible = useMemo(() => {
const text = keyword.trim().toLowerCase();
return entries.filter((entry) => {
if (knowledge && !knowledgeOf(entry).includes(knowledge)) return false;
if (!text) return true;
return (
(entry.stem ?? "").toLowerCase().includes(text) ||
entry.source_label.toLowerCase().includes(text)
);
});
}, [entries, keyword, knowledge]);
useEffect(() => {
if (visible.length === 0) {
if (selectedId !== null) setSelectedId(null);
return;
}
if (selectedId === null || !visible.some((entry) => entry.id === selectedId)) {
setSelectedId(visible[0].id);
}
}, [visible, selectedId]);
const index = visible.findIndex((entry) => entry.id === selectedId);
const current = index >= 0 ? visible[index] : null;
function step(offset: number) {
const next = visible[index + offset];
if (next) setSelectedId(next.id);
}
/>
),
},
{
title: t("errors.colDiagnosis"),
dataIndex: "diagnosis",
width: 160,
render: (value: string) => backendText(value),
},
{
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 (
<Panel>
<AsyncState loading={loading} error={error} onRetry={reload} />
{data && (
<DataTable<ErrorEntry>
rowKey="id"
columns={columns}
dataSource={data}
locale={{
emptyText: (
<EmptyState description={t("errors.empty")} />
),
}}
expandable={{
expandedRowRender: (entry) => (
<QuestionDetail
stem={entry.stem}
options={entry.options}
selected={entry.selected}
correctIndex={entry.correct_index}
explanation={entry.explanation}
legacyHint={t("errors.legacyHint")}
<SplitView
storageKey="nex_math.split.errors.aside"
defaultWidth={330}
aside={
<Pane
title={t("errors.asideTitle")}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("errors.stat", { n: entries.length })}
</Typography.Text>
}
toolbar={
<>
<Input
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder={t("errors.search")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ flex: "1 1 140px", minWidth: 130 }}
/>
),
}}
<Select
allowClear
size="small"
showSearch
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")} />
)}
</Panel>
{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,
Form,
Input,
Segmented,
Select,
Space,
Tag,
@ -12,8 +13,10 @@ import {
} from "antd";
import type { TreeDataNode } from "antd";
import {
ApartmentOutlined,
DeleteOutlined,
EditOutlined,
PartitionOutlined,
PlusOutlined,
ShareAltOutlined,
} from "@ant-design/icons";
@ -21,6 +24,7 @@ import { api } from "../api/client";
import { HeaderActions } from "../components/Layout";
import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi";
import KnowledgeGraphView from "../components/KnowledgeGraphView";
import {
EditorDrawer,
FormRow,
@ -41,6 +45,10 @@ import type {
KnowledgeGraphNode,
} from "../types";
/** 领域筛选取「全部」时的取值,取值本身不参与提交。 */
const ALL_DOMAINS = "all";
/** 树视图:按「包含」关系折叠成层级,和画布看的是同一份数据。 */
function buildTreeData(
nodes: KnowledgeGraphNode[],
edges: KnowledgeGraphEdge[],
@ -76,6 +84,9 @@ function buildTreeData(
return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item));
}
/** 图谱 / 树两种呈现方式,切换器放在筛选行的右端。 */
type GraphViewMode = "graph" | "tree";
export default function KnowledgeGraph() {
const t = useT();
@ -83,7 +94,7 @@ export default function KnowledgeGraph() {
const { data, loading, error, reload } = useApi(api.knowledgeGraph);
const coursesState = useApi(api.courses);
const [domain, setDomain] = useState("初等");
const [categoryId, setCategoryId] = useState<number>(0);
const [viewMode, setViewMode] = useState<GraphViewMode>("graph");
const [selected, setSelected] = useState<KnowledgeGraphNode | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeGraphNode | null>(null);
@ -102,79 +113,44 @@ export default function KnowledgeGraph() {
}, [data, selected]);
const nodes = useMemo(
() => (data?.nodes ?? []).filter((node) => node.domain === domain),
() =>
(data?.nodes ?? []).filter(
(node) => domain === ALL_DOMAINS || node.domain === domain,
),
[data, domain],
);
const domainRoot = nodes.find(
(node) => node.name === (domain === "初等" ? "初等数学" : "高等数学"),
const visibleIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
[nodes],
);
const categoryRoots = useMemo(
const visibleEdges = useMemo(
() =>
(data?.edges ?? [])
.filter(
(edge) =>
edge.relation_type === "包含" &&
edge.source === domainRoot?.id,
)
.map((edge) => nodes.find((node) => node.id === edge.target))
.filter((node): node is KnowledgeGraphNode => Boolean(node)),
[data, nodes, domainRoot],
(data?.edges ?? []).filter(
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
),
[data, visibleIds],
);
const subNodes = useMemo(
() => {
if (categoryId === 0) return nodes;
const children = new Map<number, number[]>();
(data?.edges ?? []).forEach((edge) => {
if (edge.relation_type === "包含") {
const list = children.get(edge.source) ?? [];
list.push(edge.target);
children.set(edge.source, list);
}
});
const ids = new Set<number>([categoryId]);
const queue = [categoryId];
while (queue.length) {
const current = queue.shift()!;
(children.get(current) ?? []).forEach((id) => {
if (!ids.has(id)) {
ids.add(id);
queue.push(id);
}
});
}
return nodes.filter((node) => ids.has(node.id));
},
[categoryId, data, nodes],
);
const subNodeIds = new Set(subNodes.map((node) => node.id));
const roots = subNodes
/** 树视图的根:在本域内没有被「包含」指向的节点(通常就是域根)。 */
const treeData = useMemo(() => {
const roots = nodes
.filter(
(node) =>
!(data?.edges ?? []).some(
!visibleEdges.some(
(edge) =>
edge.relation_type === "包含" &&
edge.target === node.id &&
subNodeIds.has(edge.source),
edge.relation_type === "包含" && edge.target === node.id,
),
)
.map((node) => node.id);
const treeData = useMemo(
() => buildTreeData(subNodes, data?.edges ?? [], roots),
[subNodes, data, roots],
);
const expandedRootKeys = roots.map(String);
return buildTreeData(nodes, visibleEdges, roots);
}, [nodes, visibleEdges]);
const nodeById = new Map(nodes.map((node) => [node.id, node]));
function openCreate() {
setEditing(null);
form.resetFields();
const defaultCategory =
categoryRoots.find((node) => node.id === categoryId)?.name ?? "";
form.setFieldsValue({
domain,
category: defaultCategory,
domain: domain === ALL_DOMAINS ? "初等" : domain,
category: selected?.category ?? "",
description: "",
});
setEditOpen(true);
@ -329,62 +305,84 @@ export default function KnowledgeGraph() {
</HeaderActions>
<Toolbar
filters={
<>
<Select
value={domain}
onChange={(value) => {
setDomain(value);
setCategoryId(0);
}}
style={{ width: 120 }}
onChange={(value) => setDomain(value)}
style={{ width: 150 }}
options={[
{ value: ALL_DOMAINS, label: t("graph.domainAll") },
{ value: "初等", label: t("graph.domainElementary") },
{ value: "高等", label: t("graph.domainAdvanced") },
]}
/>
<Select
style={{ width: 240 }}
placeholder={t("graph.domainPlaceholder")}
value={categoryId}
onChange={(value) => {
setCategoryId(value);
setSelected(null);
}}
}
extra={
<Segmented
size="small"
value={viewMode}
onChange={(value) => setViewMode(value as GraphViewMode)}
options={[
{ value: 0, label: t("graph.allCategories") },
...categoryRoots.map((node) => ({
value: node.id,
label: node.name,
})),
{
value: "graph",
label: t("graph.viewGraph"),
icon: <ApartmentOutlined />,
},
{
value: "tree",
label: t("graph.viewTree"),
icon: <PartitionOutlined />,
},
]}
/>
</>
}
/>
<AsyncState loading={loading} error={error} onRetry={reload} />
{data && (
<div className="tree-box">
{data &&
(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
showLine
selectable
defaultExpandedKeys={expandedRootKeys}
defaultExpandAll
treeData={treeData}
selectedKeys={selected ? [String(selected.id)] : []}
onSelect={(keys) => {
const id = Number(keys[0]);
const node = data?.nodes.find((item) => item.id === id);
if (node) setSelected(node);
const node =
data.nodes.find((item) => item.id === Number(keys[0])) ??
null;
setSelected(node);
}}
/>
</div>
)}
))}
</Panel>
{selected && (
{/* 右侧信息面板常驻:不管有没有选中,图谱的宽度都不会跳一下 */}
<Panel
muted
className="graph-detail"
title={t("graph.detailTitle", { name: selected.name })}
title={
selected
? t("graph.detailTitle", { name: selected.name })
: t("graph.detailTitleEmpty")
}
extra={
selected ? (
<RowActions
actions={[
{
@ -402,8 +400,13 @@ export default function KnowledgeGraph() {
},
]}
/>
) : undefined
}
>
{!selected ? (
<Hint>{t("graph.detailEmpty")}</Hint>
) : (
<>
<Descriptions
size="small"
column={2}
@ -417,6 +420,7 @@ export default function KnowledgeGraph() {
chapters: selected.chapter_count,
questions: selected.question_count,
}),
span: 2,
},
{ key: "desc", label: t("graph.fieldDescription"), children: selected.description || "—", span: 2 },
]}
@ -560,8 +564,9 @@ export default function KnowledgeGraph() {
</Space.Compact>
</div>
</Space>
</Panel>
</>
)}
</Panel>
<EditorDrawer
title={

View File

@ -1,10 +1,11 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Button,
Card,
Col,
Drawer,
Flex,
Input,
Progress,
Row,
Segmented,
@ -17,10 +18,12 @@ import {
BookOutlined,
ExperimentOutlined,
LinkOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { api } from "../api/client";
import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi";
import KnowledgeGraphView from "../components/KnowledgeGraphView";
import {
EmptyState,
Hint,
@ -31,11 +34,19 @@ import {
} from "../components/ui";
import { enumLabel, relationDirectionLabel } from "../i18n";
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";
/** 分档筛选里「不过滤」的取值。 */
const ALL_BANDS = "all";
/** 领域筛选里「不过滤」的取值。 */
const ALL_DOMAINS = "all";
interface KnowledgeMapProps {
onStartChapterTest?: (chapterId: number) => void;
@ -56,8 +67,10 @@ export default function KnowledgeMap({
}: KnowledgeMapProps) {
const t = useT();
const { data, loading, error, reload } = useApi(api.knowledge);
const { data, loading, error, reload } = useApi(api.knowledgeMap);
const [band, setBand] = useState<string>(initialBand ?? ALL_BANDS);
const [domain, setDomain] = useState<string>(ALL_DOMAINS);
const [keyword, setKeyword] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const detailState = useApi(
() => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)),
@ -65,9 +78,50 @@ export default function KnowledgeMap({
);
const detail = detailState.data;
const knowledge = data ?? [];
const points =
band === ALL_BANDS ? knowledge : knowledge.filter((item) => item.status === band);
const nodes = data?.nodes ?? [];
const edges = data?.edges ?? [];
const visibleNodes = useMemo(
() =>
nodes.filter((node) => domain === ALL_DOMAINS || node.domain === domain),
[nodes, domain],
);
const visibleIds = useMemo(
() => new Set(visibleNodes.map((node) => node.id)),
[visibleNodes],
);
const visibleEdges = useMemo(
() =>
edges.filter(
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
),
[edges, visibleIds],
);
/**
* 档位与关键词只把不相关的点淡出,不改布局:
* 地图的骨架(先修链、分类列)要一直在,才看得出“我卡在哪一段”。
*/
const dimmed = useMemo(() => {
const text = keyword.trim().toLowerCase();
const set = new Set<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<
Record<string, KnowledgeRelatedNode[]>
@ -81,7 +135,7 @@ export default function KnowledgeMap({
<Page>
<AsyncState loading={loading} error={error} onRetry={reload} />
{data &&
(data.length > 0 ? (
(nodes.length > 0 ? (
<Panel title={t("knowledge.masteryTitle")}>
<Toolbar
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 ? (
<EmptyState description={t("knowledge.emptyBand")} />
) : (
<Row gutter={[16, 16]}>
{points.map((item) => (
<Col xs={24} md={12} key={item.id}>
<Card
size="small"
hoverable
onClick={() => setSelectedId(item.knowledge_id)}
title={item.name}
extra={
<Tag color={masteryTagColor(item.mastery)}>
{enumLabel(t, item.status)}
</Tag>
}
>
<Progress
percent={item.mastery}
strokeColor={masteryProgressColor(item.mastery)}
<Flex className="kg-summary" gap={32} wrap align="center">
<Statistic
title={t("knowledge.averageMastery")}
value={average}
suffix="%"
/>
</Card>
</Col>
))}
</Row>
)}
<Statistic
title={t("knowledge.practicedCount")}
value={practiced.length}
suffix={`/ ${nodes.length}`}
/>
<Statistic
title={t("knowledge.focusCount")}
value={focusCount}
styles={
focusCount > 0 ? { content: { color: status.error } } : undefined
}
/>
</Flex>
<KnowledgeGraphView
nodes={visibleNodes}
edges={visibleEdges}
selectedId={selectedId}
onSelect={(node) => setSelectedId(node.id)}
dimmedIds={dimmed}
showMastery
domainLabel={(value) =>
value === "高等"
? t("graph.domainAdvanced")
: t("graph.domainElementary")
}
/>
<Hint>{t("knowledge.hint")}</Hint>
</Panel>
) : (
<Panel>

View File

@ -1,12 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Progress,
Segmented,
Select,
Space,
Tag,
@ -16,24 +16,28 @@ import {
import type { ColumnsType } from "antd/es/table";
import {
DeleteOutlined,
DownOutlined,
EditOutlined,
RobotOutlined,
RightOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { api } from "../api/client";
import { HeaderActions } from "../components/Layout";
import { AsyncState } from "../components/Async";
import { useApi } from "../components/useApi";
import { QuestionDetail } from "../components/QuestionDetail";
import { Pane, SplitView } from "../components/SplitView";
import { PaperMetaText, PaperReview } from "../components/PaperReview";
import type { ReviewItem } from "../components/PaperReview";
import {
DataTable,
EditorDrawer,
EmptyState,
Hint,
KnowledgeTags,
Panel,
RowActions,
SectionTitle,
SubText,
Toolbar,
rules,
useUi,
} from "../components/ui";
@ -50,6 +54,9 @@ interface QuestionBankProps {
preset?: { textbook_id?: number; chapter_id?: number } | null;
}
/** 题目浏览的两档视图:试卷卡片(默认)与管理员批量维护用的表格。 */
type BankView = "card" | "table";
export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) {
const t = useT();
@ -62,6 +69,12 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
const [filterChapterId, setFilterChapterId] = useState<number | undefined>(
() => 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 [genBusy, setGenBusy] = useState(false);
const [genTextbookId, setGenTextbookId] = useState<number | undefined>();
@ -74,9 +87,6 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
const [genForm] = Form.useForm();
const [editForm] = Form.useForm();
const filterChapters =
textbooks.find((item) => item.id === filterTextbookId)?.chapters ?? [];
const listState = useApi(
() =>
api.bankQuestions({
@ -85,6 +95,10 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
}),
[filterTextbookId, filterChapterId],
);
// 侧栏的「全部题目」必须是全库真实题量:各章 question_count 之和会漏掉
// 还没挂章节的题目,出现“全部题目 0 / 右栏共 18 题”这种自相矛盾。
const totalsState = useApi(api.bankSummary);
const totals = totalsState.data;
const llmState = useApi(api.llmSettings);
const graphState = useApi(api.knowledgeGraph);
@ -95,6 +109,16 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
}
}, [preset?.textbook_id, preset?.chapter_id]);
// 选中的书自动展开,其它书保持折叠,避免几十个章节一次性铺开
useEffect(() => {
if (!filterTextbookId) return;
setOpenBooks((prev) =>
prev.has(filterTextbookId)
? prev
: new Set(prev).add(filterTextbookId),
);
}, [filterTextbookId]);
useEffect(() => {
if (!genOpen || !llmState.data) return;
const defaultChannel = llmState.data.channels.find((channel) => channel.is_default);
@ -124,9 +148,33 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
setGenOpen(true);
}
function handleFilterTextbookChange(textbookId?: number) {
function pickChapter(textbookId?: number, chapterId?: number) {
setFilterTextbookId(textbookId);
setFilterChapterId(undefined);
setFilterChapterId(chapterId);
}
function toggleBook(textbookId: number) {
setOpenBooks((prev) => {
const next = new Set(prev);
if (next.has(textbookId)) next.delete(textbookId);
else next.add(textbookId);
return next;
});
}
const booksWithChapters = textbooks.filter(
(book) => book.chapters.length > 0,
);
const allBooksOpen =
booksWithChapters.length > 0 &&
booksWithChapters.every((book) => openBooks.has(book.id));
function toggleAllBooks() {
setOpenBooks(
allBooksOpen
? new Set()
: new Set(booksWithChapters.map((book) => book.id)),
);
}
function handleGenTextbookChange(textbookId: number) {
@ -227,6 +275,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
if (next.status === "success") {
message.success(backendText(next.message));
listState.reload();
textbooksState.reload();
totalsState.reload();
} else {
message.error(backendText(next.message));
}
@ -241,6 +291,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
await api.deleteQuestion(item.id);
message.success(t("common.msgDeleted"));
listState.reload();
textbooksState.reload();
totalsState.reload();
} catch (err) {
message.error(err instanceof Error ? err.message : t("common.msgDeleteFailed"));
}
@ -296,6 +348,7 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
message.success(t("bank.msgUpdated", { id: updated.id }));
setEditOpen(false);
listState.reload();
totalsState.reload();
} catch (err) {
message.error(err instanceof Error ? err.message : t("common.msgSaveFailed"));
} finally {
@ -303,69 +356,40 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
}
}
const columns: ColumnsType<QuestionBankItem> = [
{ title: "ID", dataIndex: "id", width: 70 },
{
title: t("bank.colQuestion"),
dataIndex: "stem",
render: (value: string) => (
<span style={{ fontWeight: 600 }}>
<RichText content={value} compact />
</span>
),
},
{
title: t("analytics.knowledge"),
dataIndex: "knowledge_name",
width: 220,
render: (_value: string, item) => (
<KnowledgeTags
names={
item.knowledge_names?.length > 0
? item.knowledge_names
: [item.knowledge_name]
}
/>
),
},
{
title: t("bank.colSource"),
key: "origin",
width: 120,
render: (_, item) => (
<Space orientation="vertical" size={4}>
<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"),
dataIndex: "difficulty",
width: 90,
render: (value: number) => (
<Tag color={tagColor.info}>{"★".repeat(value)}</Tag>
),
},
{
title: t("bank.colUsage"),
dataIndex: "used_count",
width: 130,
render: (value: number) =>
(value ?? 0) > 0 ? (
<Tag color={tagColor.warning}>{t("bank.usedN", { n: value })}</Tag>
) : (
<Tag>{t("bank.unused")}</Tag>
),
},
{
title: t("common.actions"),
key: "action",
width: 140,
fixed: "right",
render: (_, item) => {
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
@ -399,13 +423,134 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
]}
/>
);
}
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: t("bank.colQuestion"),
dataIndex: "stem",
render: (value: string) => (
<span style={{ fontWeight: 600 }}>
<RichText content={value} compact />
</span>
),
},
{
title: t("analytics.knowledge"),
key: "knowledge",
width: 200,
render: (_, item) => (
<KnowledgeTags
names={
item.knowledge_names?.length > 0
? item.knowledge_names
: [item.knowledge_name]
}
/>
),
},
{
title: t("bank.colChapter"),
dataIndex: "chapter_name",
width: 160,
render: (_, item) => (
<SubText as="span">{item.chapter_name || t("bank.noChapter")}</SubText>
),
},
{
title: t("bank.colDifficulty"),
dataIndex: "difficulty",
width: 90,
render: (value: number) => <Tag color={tagColor.info}>{"★".repeat(value)}</Tag>,
},
{
title: t("bank.colUsage"),
dataIndex: "used_count",
width: 120,
render: (value: number) =>
(value ?? 0) > 0 ? (
<Tag color={tagColor.warning}>{t("bank.usedN", { n: value })}</Tag>
) : (
<Tag>{t("bank.unused")}</Tag>
),
},
{
title: t("common.actions"),
key: "action",
width: 150,
fixed: "right",
render: (_, item) => questionActions(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 (
<>
<Panel>
<HeaderActions>
<Button
type="primary"
@ -416,56 +561,9 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
{t("bank.generate")}
</Button>
</HeaderActions>
<Toolbar
filters={
<>
<Select
allowClear
placeholder={t("bank.allTextbooks")}
style={{ width: 260 }}
value={filterTextbookId}
onChange={handleFilterTextbookChange}
options={textbooks.map((book) => ({
value: book.id,
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 }}>
<div className="bank-task">
<SectionTitle>{t("bank.taskTitle", { id: activeTask.id })}</SectionTitle>
<Progress
percent={activeTask.progress}
@ -480,6 +578,221 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
<SubText>{backendText(activeTask.message)}</SubText>
</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}
@ -488,35 +801,22 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
{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}
/>
),
columns={tableColumns}
scroll={{ x: 1180 }}
dataSource={filtered}
locale={{ emptyText: emptyHint }}
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => t("common.totalRows", { n: total }),
}}
/>
)}
</Panel>
</div>
</div>
)
}
/>
<EditorDrawer
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 { 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 {
DataTable,
EmptyState,
Hint,
KnowledgeTags,
Panel,
SubText,
Toolbar,
} from "../components/ui";
import { EmptyState } from "../components/ui";
import type { ViewId } from "../nav";
import { useT } from "../prefs";
import { tagColor } from "../theme";
import type { AttemptItemDetail, RecordDetail, RecordItem } from "../types";
import type { RecordDetail, RecordItem } from "../types";
function ItemView({ item }: { item: AttemptItemDetail }) {
const t = useT();
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>
);
interface RecordsProps {
onNavigate?: (view: ViewId) => void;
}
export default function Records() {
function accuracyOf(record: { correct: number; total: number }) {
return record.total ? Math.round((record.correct / record.total) * 100) : 0;
}
export default function Records({ onNavigate }: RecordsProps) {
const t = useT();
const { data, loading, error, reload } = useApi(api.records);
const listState = useApi(api.records);
const records = useMemo(() => listState.data ?? [], [listState.data]);
const [keyword, setKeyword] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
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>>({});
async function loadDetail(record: RecordItem) {
if (details[record.id] || loadingId !== null) return;
setLoadingId(record.id);
const visible = useMemo(() => {
const text = keyword.trim().toLowerCase();
if (!text) return records;
return records.filter(
(record) =>
record.title.toLowerCase().includes(text) ||
record.happened_on.toLowerCase().includes(text),
);
}, [records, keyword]);
// 默认选中最近一次训练,进页面就能看到卷子
useEffect(() => {
if (selectedId === null && records.length > 0) {
setSelectedId(records[0].id);
}
}, [records, selectedId]);
async function loadDetail(id: number) {
if (details[id] || pendingId === id) return;
setPendingId(id);
setDetailErrors((prev) => {
const next = { ...prev };
delete next[record.id];
delete next[id];
return next;
});
try {
const detail = await api.recordDetail(record.id);
setDetails((prev) => ({ ...prev, [record.id]: detail }));
} catch (err) {
setDetailErrors((prev) => ({
...prev,
[record.id]: t("common.loadFailed"),
}));
const detail = await api.recordDetail(id);
setDetails((prev) => ({ ...prev, [id]: detail }));
} catch {
setDetailErrors((prev) => ({ ...prev, [id]: t("common.loadFailed") }));
} finally {
setLoadingId(null);
setPendingId(null);
}
}
const columns: ColumnsType<RecordItem> = [
{ title: t("records.colTime"), dataIndex: "happened_on", width: 130 },
{ title: t("records.colTraining"), dataIndex: "title" },
{ title: t("records.colTotal"), dataIndex: "total", width: 90, align: "center" },
{ title: t("records.colCorrect"), dataIndex: "correct", width: 90, align: "center" },
{
title: t("records.colRate"),
key: "accuracy",
width: 240,
render: (_: unknown, record) => {
const percent = record.total
? Math.round((record.correct / record.total) * 100)
: 0;
useEffect(() => {
if (selectedId !== null) void loadDetail(selectedId);
// 选中变化时补拉明细,其余依赖都是稳定引用
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
const selected = selectedId === null ? null : (details[selectedId] ?? null);
const selectedError = selectedId === null ? null : detailErrors[selectedId];
const loadingSelected =
selectedId !== null && !selected && !selectedError && pendingId === selectedId;
const items: ReviewItem[] = (selected?.items ?? []).map((item) => ({
key: `${item.question_id ?? "q"}-${item.question_index}`,
stem: item.stem,
options: item.options,
selected: item.selected,
correctIndex: item.correct_index,
explanation: item.explanation,
correct: item.correct,
knowledgeNames: item.knowledge_names?.length
? item.knowledge_names
: [item.knowledge_name],
tags:
(item.correct === false) ? (
<Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
{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 (
<Space>
<SplitView
storageKey="nex_math.split.records.aside"
defaultWidth={318}
aside={
<Pane
title={t("records.asideTitle")}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t("records.stat", {
sessions: stats.sessions,
accuracy: stats.accuracy,
})}
</Typography.Text>
}
toolbar={
<Input
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder={t("records.search")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
}
>
<AsyncState loading={listState.loading} error={listState.error} onRetry={listState.reload} compact />
{listState.data && visible.length === 0 && (
<EmptyState compact description={t("records.empty")} />
)}
{visible.map((record: RecordItem) => {
const percent = accuracyOf(record);
const active = record.id === selectedId;
return (
<button
type="button"
key={record.id}
className={`master-item${active ? " master-item--active" : ""}`}
onClick={() => setSelectedId(record.id)}
>
<div className="master-item-title">
<span style={{ flex: 1, minWidth: 0 }}>{record.title}</span>
<ScoreChip correct={record.correct} total={record.total} />
</div>
<div className="master-item-sub">
{record.happened_on} · {t("records.summary", {
correct: record.correct,
total: record.total,
})}
</div>
<Progress
percent={percent}
size="small"
style={{ width: 120, margin: 0 }}
showInfo={false}
style={{ marginTop: 6, marginBottom: 0 }}
/>
{record.total - record.correct > 0 ? (
<Tag color={tagColor.negative}>{t("records.wrongN", { n: record.total - record.correct })}</Tag>
) : (
<Tag color={tagColor.positive}>{t("records.allCorrect")}</Tag>
)}
</Space>
</button>
);
},
},
];
return (
<Panel>
<Toolbar filters={<Hint>{t("records.expandHint")}</Hint>} />
<AsyncState loading={loading} error={error} onRetry={reload} />
{data && (
<DataTable<RecordItem>
rowKey="id"
columns={columns}
dataSource={data}
locale={{
emptyText: (
<EmptyState description={t("records.empty")} />
),
}}
expandable={{
onExpand: (expanded, record) => {
if (expanded) void loadDetail(record);
},
expandedRowRender: (record) => {
if (loadingId === record.id) {
return <AsyncState loading compact />;
})}
</Pane>
}
if (detailErrors[record.id]) {
return (
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={detailErrors[record.id]}
title={selectedError}
action={
<Button
size="small"
danger
onClick={() => void loadDetail(record)}
>
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
}
/>
)
}
/>
);
}
const detail = details[record.id];
if (!detail) {
return <SubText>{t("records.detailPending")}</SubText>;
}
return (
<Space orientation="vertical" size={14} style={{ width: "100%" }}>
{detail.items.map((item) => (
<ItemView key={`${item.question_id}-${item.question_index}`} item={item} />
))}
</Space>
);
},
}}
/>
)}
</Panel>
);
}

View File

@ -17,6 +17,9 @@
--track: #f1f2f6;
--track-raised: #ffffff;
--brand-soft: rgba(91, 92, 226, 0.08);
--select-bg: var(--brand-soft);
--select-border: rgba(91, 92, 226, 0.45);
--select-glow: 0 0 0 3px rgba(91, 92, 226, 0.12);
--success-soft: #f0fdf4;
--success-soft-border: #bbf7d0;
--danger-soft: #fef2f2;
@ -47,6 +50,9 @@
--track: #24263c;
--track-raised: #3a3e5c;
--brand-soft: rgba(91, 92, 226, 0.22);
--select-bg: var(--brand-soft);
--select-border: rgba(129, 130, 235, 0.55);
--select-glow: 0 0 0 3px rgba(129, 130, 235, 0.18);
--success-soft: rgba(22, 163, 74, 0.14);
--success-soft-border: rgba(22, 163, 74, 0.4);
--danger-soft: rgba(220, 38, 38, 0.14);
@ -606,6 +612,13 @@ body {
margin-bottom: 16px;
}
.panel-toolbar-extra {
display: flex;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.section-title {
display: flex;
@ -735,6 +748,22 @@ body {
opacity: 0.7;
}
/* 选中项与全站同一套「浅底 + 品牌描边」;右侧的对勾保证一眼可辨 */
.option-row--active,
.option-row--active:hover,
.option-row--active:focus {
background: var(--select-bg) !important;
border-color: var(--select-border) !important;
color: var(--text-strong) !important;
box-shadow: inset 0 0 0 1px var(--select-border);
}
.option-row-check {
margin-inline-start: auto;
padding-inline-start: 10px;
color: var(--brand);
}
/* 答题题干:练习流程统一排版 */
.question-stem {
font-size: 17px;
@ -779,6 +808,281 @@ body {
min-width: min(520px, 100%);
}
/* ---------------- 知识图谱画布(知识地图 / 图谱管理共用) ---------------- */
.kg-wrap {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--border-soft);
border-radius: 14px;
background: var(--surface);
overflow: hidden;
}
.kg-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
background: var(--surface-2);
}
.kg-hint {
font-size: 12px;
color: var(--text-secondary);
}
.kg-zoom {
display: inline-flex;
align-items: center;
gap: 2px;
}
.kg-zoom-value {
min-width: 42px;
text-align: center;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.kg-canvas {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
background-image: radial-gradient(var(--border-soft) 1px, transparent 1px);
background-size: 22px 22px;
cursor: grab;
touch-action: none;
}
.kg-canvas:active {
cursor: grabbing;
}
.kg-content {
position: relative;
transform-origin: 0 0;
}
.kg-edges {
position: absolute;
inset: 0;
pointer-events: none;
}
.kg-edge {
fill: none;
stroke: var(--border-soft);
stroke-width: 1.4;
transition: opacity 0.18s ease, stroke 0.18s ease;
}
.kg-edge--前置 {
stroke: rgba(91, 92, 226, 0.5);
stroke-width: 1.8;
}
.kg-edge--相关 {
stroke-dasharray: 2 4;
}
.kg-edge--包含 {
stroke-dasharray: 5 5;
}
.kg-edge--focus {
stroke: var(--brand);
stroke-width: 2.6;
}
.kg-edge--faded {
opacity: 0.18;
}
.kg-edge--dim {
opacity: 0.12;
}
.kg-arrow-head {
fill: rgba(91, 92, 226, 0.75);
}
/* 域标题:一条横贯本域所有分类的细线 + 标题 */
.kg-band {
position: absolute;
top: 14px;
height: 26px;
padding: 0 8px;
display: flex;
align-items: center;
}
.kg-band::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 0;
height: 1px;
background: var(--border-soft);
}
.kg-band-label {
position: relative;
z-index: 1;
padding: 0 10px;
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand);
font-size: 12px;
font-weight: 600;
line-height: 22px;
}
.kg-column-head {
position: absolute;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--surface-2);
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
}
.kg-column-head:hover {
border-color: var(--select-border);
}
.kg-column-name {
font-size: 13px;
font-weight: 650;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kg-column-meta {
flex: none;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.kg-node {
position: absolute;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--border-soft);
border-radius: 9px;
background: var(--surface);
cursor: pointer;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease,
opacity 0.18s ease;
}
.kg-node:hover {
border-color: var(--brand);
}
.kg-node-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-tone, var(--border-soft));
}
.kg-node--practiced {
border-color: var(--select-border);
border-color: color-mix(in srgb, var(--node-tone) 45%, var(--border-soft));
background: var(--brand-soft);
background: color-mix(in srgb, var(--node-tone) 8%, var(--surface));
}
.kg-node--unpracticed .kg-node-dot {
background: transparent;
border: 1px dashed var(--text-muted);
}
.kg-node-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--text-strong);
}
.kg-node--unpracticed .kg-node-name {
color: var(--text-secondary);
}
.kg-node-mastery {
flex: none;
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.kg-node--selected {
border-color: var(--select-border);
background: var(--select-bg);
box-shadow: var(--select-glow);
}
.kg-node--faded {
opacity: 0.32;
}
.kg-node--dim {
opacity: 0.18;
}
.kg-legend {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 8px 12px;
border-top: 1px solid var(--border-soft);
background: var(--surface-2);
font-size: 12px;
color: var(--text-secondary);
}
.kg-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.kg-legend-dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
.kg-legend-dot--empty {
background: transparent;
border: 1px dashed var(--text-muted);
}
.graph-detail {
width: 380px;
max-width: 100%;
@ -786,12 +1090,13 @@ body {
top: 76px;
}
.tree-box {
max-height: 620px;
/* 树视图:和画布同一块高度,长了在自己内部滚 */
.graph-tree {
max-height: 560px;
overflow: auto;
padding: 12px;
padding: 12px 14px;
border: 1px solid var(--border-soft);
border-radius: 10px;
border-radius: 14px;
background: var(--surface);
}
@ -893,11 +1198,11 @@ body {
color: inherit;
}
/* 选中项:白底小块浮在面板上,底色与右侧内容一致 */
/* 选中项:与其他地方的「选中」同一套语义(浅品牌底 + 品牌描边) */
.resource-tabs .ant-tabs-tab-active,
.resource-tabs .ant-tabs-tab-active:hover {
background: var(--surface);
box-shadow: 0 1px 3px rgba(15, 17, 45, 0.14);
background: var(--select-bg);
box-shadow: inset 0 0 0 1px var(--select-border);
}
.resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
@ -1258,9 +1563,9 @@ body {
}
.daily-rate--active {
background: var(--brand-soft);
border-color: var(--brand);
box-shadow: inset 0 0 0 1px var(--brand);
background: var(--select-bg);
border-color: var(--select-border);
box-shadow: inset 0 0 0 1px var(--select-border);
}
.daily-rate-icon {
@ -1510,8 +1815,8 @@ body.split-dragging {
}
.master-item--active {
background: var(--brand-soft);
border-color: rgba(91, 92, 226, 0.35);
background: var(--select-bg);
border-color: var(--select-border);
}
.master-item-title {
@ -1640,8 +1945,9 @@ body.split-dragging {
}
.paper-question--active {
background: var(--surface-2);
box-shadow: inset 3px 0 0 var(--brand);
background: var(--select-bg);
/* 整圈细描边取代原来的左侧指示条:卡片感更强,也不偏不倚 */
box-shadow: inset 0 0 0 1px var(--select-border);
}
.paper-question-rail {
@ -1765,8 +2071,8 @@ body.split-dragging {
}
.answer-chip--active {
border-color: var(--brand);
box-shadow: 0 0 0 2px var(--brand-soft);
border-color: var(--select-border);
box-shadow: var(--select-glow);
}
.answer-chip-icon {
@ -1786,7 +2092,8 @@ body.split-dragging {
}
.answer-chip--done {
border-color: rgba(91, 92, 226, 0.45);
background: var(--select-bg);
border-color: var(--select-border);
color: var(--brand);
}
@ -1795,59 +2102,62 @@ body.split-dragging {
color: var(--text-muted);
}
.answer-sheet-legend {
.answer-sheet-pager {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--text-secondary);
}
.answer-sheet-pager-text {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 答题侧栏的「当前题」卡片:滚动到哪一题、这题答没答 */
.sheet-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
padding: 10px 12px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--surface-2);
}
.answer-sheet-legend-toggle {
.sheet-current-label {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
}
.sheet-current-state {
font-size: 12.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.sheet-current-state--done {
color: var(--brand);
font-weight: 600;
}
.sheet-jump {
align-self: flex-start;
margin-top: 10px;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-decoration: underline dotted;
}
.answer-sheet-legend-items {
display: flex;
}
.answer-sheet-legend-items span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.answer-dot {
width: 9px;
height: 9px;
border-radius: 3px;
display: inline-block;
}
.answer-dot--correct {
background: var(--success-soft);
border: 1px solid #16a34a;
}
.answer-dot--wrong {
background: var(--danger-soft);
border: 1px solid #dc2626;
}
.answer-dot--done {
background: var(--brand-soft);
border: 1px solid var(--brand);
}
.answer-dot--pending {
background: transparent;
border: 1px dashed var(--text-muted);
color: var(--brand);
font-size: 12.5px;
cursor: pointer;
}
.sheet-jump:hover {
text-decoration: underline;
}
.answer-sheet-summary {
@ -1913,30 +2223,135 @@ body.split-dragging {
}
/* 清单里的可点分组(教材名)与更紧凑的子项(章节) */
.master-group-button {
.master-tree-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-strong);
font: inherit;
text-align: start;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
justify-content: space-between;
gap: 8px;
margin: 4px 0 6px;
padding: 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.master-group-button:hover {
.master-tree-toggle {
border: 0;
padding: 0;
background: transparent;
color: var(--brand);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.master-tree-toggle:hover {
text-decoration: underline;
}
/* 一本书:一行标题 + 折叠起来的章节列表 */
.master-book {
margin-bottom: 2px;
}
.master-book-row {
display: flex;
align-items: center;
gap: 2px;
border: 1px solid transparent;
border-radius: 8px;
}
.master-book-row:hover {
background: var(--surface-hover);
}
.master-group-button--active {
.master-book-row--active {
background: var(--select-bg);
border-color: var(--select-border);
}
.master-caret {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-inline-start: 4px;
border: 0;
border-radius: 6px;
padding: 0;
background: transparent;
color: var(--text-muted);
font-size: 10px;
cursor: pointer;
}
.master-caret:hover:not(:disabled) {
color: var(--brand);
}
.master-caret:disabled {
cursor: default;
}
.master-caret-empty {
color: var(--text-muted);
}
.master-book-button {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 4px;
border: 0;
background: transparent;
color: var(--text-strong);
font: inherit;
font-weight: 600;
font-size: 13px;
text-align: start;
cursor: pointer;
}
.master-book-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 章节缩进 + 左侧引导线,和书名的层级一眼分得清 */
.master-chapters {
margin: 2px 0 6px 14px;
padding-inline-start: 8px;
border-inline-start: 1px solid var(--scrollbar);
}
.master-chapter-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.master-item--zero .master-item-title,
.master-item--zero .chapter-nav-count {
color: var(--text-muted);
font-weight: 400;
}
.master-book-empty {
margin: 0 0 4px 26px;
font-size: 12px;
color: var(--text-muted);
}
.master-item--tight {
padding: 7px 10px;
margin-bottom: 2px;
@ -2032,6 +2447,10 @@ body.split-dragging {
max-width: 100%;
}
.test-launcher-count {
width: 118px;
}
.test-launcher-meta {
display: flex;
align-items: center;

View File

@ -279,6 +279,15 @@ export interface QuestionBankItem {
knowledge_ids: number[];
}
/** 题库侧栏计数:全库真实题量,与按章节累加的 question_count 不同。 */
export interface QuestionBankSummary {
total: number;
unattached: number;
generated: number;
used: number;
}
export interface QuestionUpdatePayload {
stem: string;
options: string[];
@ -359,6 +368,25 @@ export interface KnowledgeGraphData {
edges: KnowledgeGraphEdge[];
}
/** 学生视角的知识节点:结构 + 本人掌握度(未练过的也返回,practiced 为假)。 */
export interface KnowledgeMapNode {
id: number;
name: string;
domain: string;
category: string;
description: string;
mastery: number;
status: string;
practiced: boolean;
chapter_count: number;
question_count: number;
}
export interface KnowledgeMapData {
nodes: KnowledgeMapNode[];
edges: KnowledgeGraphEdge[];
}
export interface KnowledgeNodeCreatePayload {
name: string;
domain: string;

View File

@ -1,33 +1,112 @@
#!/usr/bin/env bash
# 初始化:安装后端依赖并写入种子数据(data/math.db)。
# 初始化本机运行环境:Python 虚拟环境 + 后端依赖 + 种子数据(+ 前端依赖)。
#
# ./scripts/init.sh 准备后端与前端环境
# ./scripts/init.sh --no-web 只准备后端
#
# 可用环境变量:
# PIP_INDEX_URL 指定 pip/uv 的包源(默认走官方源,失败自动改用清华镜像)
# NPM_REGISTRY 指定 npm 包源(失败自动改用 npmmirror)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
mkdir -p data
if [[ ! -x backend/.venv/bin/python ]]; then
if command -v uv >/dev/null 2>&1; then
echo "[init] 使用 uv 创建 Python 虚拟环境…"
(cd backend && uv venv .venv --python 3.12)
WITH_WEB=1
for arg in "$@"; do
case "$arg" in
--no-web) WITH_WEB=0 ;;
--web) WITH_WEB=1 ;;
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
*) echo "[init] 未知参数:$arg(可用:--no-web / --web)" >&2; exit 2 ;;
esac
done
# ---------- 1. 定位 uv(PATH → ~/.local/bin → ~/.cargo/bin) ----------
UV=""
for cand in uv "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
if command -v "$cand" >/dev/null 2>&1; then
UV="$(command -v "$cand")"
break
fi
done
# ---------- 2. 定位 Python 3.10+(系统 python3 常常太旧,优先带版本号的解释器) ----------
py_ok() { "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; }
pick_python() {
for p in python3.13 python3.12 python3.11 python3.10 python3; do
if command -v "$p" >/dev/null 2>&1 && py_ok "$p"; then command -v "$p"; return 0; fi
done
for p in /opt/homebrew/bin/python3.1* /usr/local/bin/python3.1*; do
if [ -x "$p" ] && py_ok "$p"; then echo "$p"; return 0; fi
done
return 1
}
# ---------- 3. 虚拟环境 ----------
if [ -x backend/.venv/bin/python ]; then
echo "[init] 复用已有虚拟环境 backend/.venv($(backend/.venv/bin/python -V 2>&1))"
elif [ -n "$UV" ]; then
echo "[init] 使用 uv($UV)创建虚拟环境,优先 CPython 3.12…"
(cd backend && "$UV" venv .venv --python 3.12) 2>/dev/null || (cd backend && "$UV" venv .venv)
else
if python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then
echo "[init] 使用 python3 创建虚拟环境…"
python3 -m venv backend/.venv
else
echo "[init] 未找到 Python 3.10+,请安装 uv(https://docs.astral.sh/uv/)后重试。" >&2
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
fi
echo "[init] 使用 $("$PY" -V 2>&1)($PY)创建虚拟环境…"
"$PY" -m venv backend/.venv
fi
if command -v uv >/dev/null 2>&1; then
uv pip install --python backend/.venv/bin/python -r backend/requirements.txt
# ---------- 4. 后端依赖(默认源失败后自动改用国内镜像) ----------
install_backend() {
if [ -n "$UV" ]; then
(cd backend && "$UV" pip install --python .venv/bin/python ${1:+--index-url "$1"} -r requirements.txt)
else
backend/.venv/bin/pip install -r backend/requirements.txt
(cd backend && .venv/bin/pip install ${1:+-i "$1"} -r requirements.txt)
fi
}
if ! install_backend "${PIP_INDEX_URL:-}"; then
echo "[init] 默认包源安装失败,改用清华镜像重试…" >&2
install_backend https://pypi.tuna.tsinghua.edu.cn/simple
fi
# ---------- 5. 种子数据 ----------
if [ -f data/math.db ]; then
echo "[init] data/math.db 已存在,跳过种子数据(重建:cd backend && .venv/bin/python -m seed --force)"
else
(cd backend && .venv/bin/python -m seed)
fi
echo "[init] 完成。启动方式:./scripts/start.sh"
# ---------- 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
echo
echo "[init] 环境就绪:"
echo " Python $(backend/.venv/bin/python -V 2>&1) → backend/.venv"
echo " Node $(node -v 2>/dev/null || echo '未安装')"
echo " 数据库 $ROOT_DIR/data/math.db"
echo "[init] 启动:./scripts/start.sh → http://localhost:5173"

View File

@ -1,8 +1,11 @@
#!/usr/bin/env bash
# 同时启动后端(FastAPI :8000)与前端(Vite :5173)。
# 端口可用 BACKEND_PORT / FRONTEND_PORT 覆盖。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
if [[ ! -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]]; then
echo "尚未初始化,请先运行 ./scripts/init.sh" >&2
@ -12,6 +15,24 @@ if [[ ! -f "$ROOT_DIR/data/math.db" ]]; then
echo "数据库不存在,请先运行 ./scripts/init.sh" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "未找到 Node.js,前端无法启动。请安装 Node 20.19+ / 22.12+(或先只跑后端:cd backend && .venv/bin/uvicorn main:app --reload)" >&2
exit 1
fi
# 端口占用早失败,避免 Vite 报一堆堆栈
require_free_port() {
local port="$1" name="$2"
command -v lsof >/dev/null 2>&1 || return 0
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
echo "端口 $port($name)已被占用:" >&2
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
echo "请先停掉占用进程,或用 ${name}_PORT=其他端口 ./scripts/start.sh 换端口。" >&2
exit 1
fi
}
require_free_port "$BACKEND_PORT" BACKEND
require_free_port "$FRONTEND_PORT" FRONTEND
if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
echo "[start] 安装前端依赖…"
@ -26,11 +47,11 @@ cleanup() {
}
trap cleanup EXIT INT TERM
echo "[start] 后端:http://localhost:${BACKEND_PORT:-8000}/api/health"
echo "[start] 后端:http://localhost:${BACKEND_PORT}/api/health"
(cd "$ROOT_DIR/backend" && exec .venv/bin/uvicorn main:app \
--host 0.0.0.0 --port "${BACKEND_PORT:-8000}" --reload) &
--host 0.0.0.0 --port "${BACKEND_PORT}" --reload) &
BACKEND_PID=$!
echo "[start] 前端:http://localhost:${FRONTEND_PORT:-5173}"
echo "[start] 前端:http://localhost:${FRONTEND_PORT}"
(cd "$ROOT_DIR/frontend" && exec npm run dev -- \
--host 0.0.0.0 --port "${FRONTEND_PORT:-5173}")
--host 0.0.0.0 --port "${FRONTEND_PORT}")