v 0.1.1
parent
b3dc8526f6
commit
f61a0bb913
10
.env.example
10
.env.example
|
|
@ -1,3 +1,9 @@
|
|||
# backend/database.py 在导入时读取本文件:仓库根 .env 优先,backend/.env 补缺,
|
||||
# 已存在的进程环境变量不会被覆盖(环境变量 > .env > 代码默认值)。
|
||||
#
|
||||
# 本机已经有一份可直接用的 .env(含绝对路径的 DATABASE_URL);
|
||||
# 这个文件是换机器/重新部署时的模板。
|
||||
|
||||
# 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db
|
||||
# 例:sqlite:////Users/you/nex_math/data/math.db
|
||||
DATABASE_URL=
|
||||
|
|
@ -5,8 +11,8 @@ DATABASE_URL=
|
|||
# 允许跨域访问的前端地址,逗号分隔
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置)
|
||||
# 可选:仅在初始化(python -m seed)建默认模型通道时使用
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# JWT 签名密钥(生产环境务必改成随机长字符串)
|
||||
# JWT 签名密钥(生产环境务必改成随机长字符串;改动后所有人需重新登录)
|
||||
JWT_SECRET=
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -182,9 +182,11 @@ docker compose up --build
|
|||
|
||||
**有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
|
||||
是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
|
||||
与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
|
||||
会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
|
||||
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
|
||||
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。
|
||||
**注意**:补章节只发生在初始化(`python -m seed`)时;后端每次启动只对齐
|
||||
表结构,不会再补数据,所以把自己准备好的 `math.db` 放回去后重启即原样生效。
|
||||
|
||||
**没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。
|
||||
管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式:
|
||||
|
|
@ -286,14 +288,21 @@ docker compose up --build
|
|||
|
||||
## 配置
|
||||
|
||||
复制 `.env.example` 并按需修改(本机直接运行时使用环境变量):
|
||||
后端启动时(`backend/database.py` 导入阶段)会读取 `.env`,优先级为
|
||||
**进程环境变量 > 仓库根 `.env` > `backend/.env` > 代码默认值**;
|
||||
容器里 `docker-compose.yml` 注入的 `DATABASE_URL` 因此依然优先。
|
||||
本机已有一份可用的 `.env`,换机器时复制 `.env.example` 按需修改即可。
|
||||
|
||||
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`
|
||||
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`;本机 `.env` 里写的是
|
||||
绝对路径,直接就能看到当前实际连的库
|
||||
- `CORS_ORIGINS`:允许访问的前端地址
|
||||
- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建
|
||||
“OpenAI · gpt-4o-mini”默认通道并使用该 Key;
|
||||
- `OPENAI_API_KEY`:可选。只在初始化(`python -m seed`)时用来创建
|
||||
“OpenAI · gpt-4o-mini”默认通道;正常使用的模型通道请在「模型配置」里维护,
|
||||
`ai_teacher.py` 的逐题讲解仍为本地规则实现
|
||||
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置
|
||||
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置(改后所有人需要重新登录)
|
||||
|
||||
**换库**:停掉后端 → 覆盖 `data/math.db` → 重启。启动只会建表 / 补列,
|
||||
不会写入任何业务数据,覆盖回来的库保持原样。
|
||||
|
||||
## 重置演示数据
|
||||
|
||||
|
|
@ -335,5 +344,6 @@ cd backend
|
|||
或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。
|
||||
* 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。
|
||||
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
|
||||
若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书
|
||||
(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
|
||||
若库丢了,初始化时的 `seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的
|
||||
已知电子书(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
|
||||
(这一步只在 `python -m seed` 时执行,后端启动不再改动库与电子书文件。)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = "初等"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -156,7 +156,7 @@ function pageFor(
|
|||
case "errors":
|
||||
return <Errors />;
|
||||
case "records":
|
||||
return <Records />;
|
||||
return <Records onNavigate={navigate} />;
|
||||
case "daily":
|
||||
return (
|
||||
<Daily
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 面板内的小节标题。 */
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? "" })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
111
scripts/init.sh
111
scripts/init.sh
|
|
@ -1,33 +1,112 @@
|
|||
#!/usr/bin/env bash
|
||||
# 初始化:安装后端依赖并写入种子数据(data/math.db)。
|
||||
# 初始化本机运行环境:Python 虚拟环境 + 后端依赖 + 种子数据(+ 前端依赖)。
|
||||
#
|
||||
# ./scripts/init.sh 准备后端与前端环境
|
||||
# ./scripts/init.sh --no-web 只准备后端
|
||||
#
|
||||
# 可用环境变量:
|
||||
# PIP_INDEX_URL 指定 pip/uv 的包源(默认走官方源,失败自动改用清华镜像)
|
||||
# NPM_REGISTRY 指定 npm 包源(失败自动改用 npmmirror)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
mkdir -p data
|
||||
|
||||
if [[ ! -x backend/.venv/bin/python ]]; then
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
echo "[init] 使用 uv 创建 Python 虚拟环境…"
|
||||
(cd backend && uv venv .venv --python 3.12)
|
||||
WITH_WEB=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-web) WITH_WEB=0 ;;
|
||||
--web) WITH_WEB=1 ;;
|
||||
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
|
||||
*) echo "[init] 未知参数:$arg(可用:--no-web / --web)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 1. 定位 uv(PATH → ~/.local/bin → ~/.cargo/bin) ----------
|
||||
UV=""
|
||||
for cand in uv "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||
if command -v "$cand" >/dev/null 2>&1; then
|
||||
UV="$(command -v "$cand")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 2. 定位 Python 3.10+(系统 python3 常常太旧,优先带版本号的解释器) ----------
|
||||
py_ok() { "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; }
|
||||
|
||||
pick_python() {
|
||||
for p in python3.13 python3.12 python3.11 python3.10 python3; do
|
||||
if command -v "$p" >/dev/null 2>&1 && py_ok "$p"; then command -v "$p"; return 0; fi
|
||||
done
|
||||
for p in /opt/homebrew/bin/python3.1* /usr/local/bin/python3.1*; do
|
||||
if [ -x "$p" ] && py_ok "$p"; then echo "$p"; return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 3. 虚拟环境 ----------
|
||||
if [ -x backend/.venv/bin/python ]; then
|
||||
echo "[init] 复用已有虚拟环境 backend/.venv($(backend/.venv/bin/python -V 2>&1))"
|
||||
elif [ -n "$UV" ]; then
|
||||
echo "[init] 使用 uv($UV)创建虚拟环境,优先 CPython 3.12…"
|
||||
(cd backend && "$UV" venv .venv --python 3.12) 2>/dev/null || (cd backend && "$UV" venv .venv)
|
||||
else
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue