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
|
# 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db
|
||||||
# 例:sqlite:////Users/you/nex_math/data/math.db
|
# 例:sqlite:////Users/you/nex_math/data/math.db
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
|
@ -5,8 +11,8 @@ DATABASE_URL=
|
||||||
# 允许跨域访问的前端地址,逗号分隔
|
# 允许跨域访问的前端地址,逗号分隔
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置)
|
# 可选:仅在初始化(python -m seed)建默认模型通道时使用
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# JWT 签名密钥(生产环境务必改成随机长字符串)
|
# JWT 签名密钥(生产环境务必改成随机长字符串;改动后所有人需重新登录)
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -182,9 +182,11 @@ docker compose up --build
|
||||||
|
|
||||||
**有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
|
**有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
|
||||||
是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
|
是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
|
||||||
与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
|
会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
|
||||||
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
|
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
|
||||||
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。
|
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。
|
||||||
|
**注意**:补章节只发生在初始化(`python -m seed`)时;后端每次启动只对齐
|
||||||
|
表结构,不会再补数据,所以把自己准备好的 `math.db` 放回去后重启即原样生效。
|
||||||
|
|
||||||
**没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。
|
**没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。
|
||||||
管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式:
|
管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式:
|
||||||
|
|
@ -286,14 +288,21 @@ docker compose up --build
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
复制 `.env.example` 并按需修改(本机直接运行时使用环境变量):
|
后端启动时(`backend/database.py` 导入阶段)会读取 `.env`,优先级为
|
||||||
|
**进程环境变量 > 仓库根 `.env` > `backend/.env` > 代码默认值**;
|
||||||
|
容器里 `docker-compose.yml` 注入的 `DATABASE_URL` 因此依然优先。
|
||||||
|
本机已有一份可用的 `.env`,换机器时复制 `.env.example` 按需修改即可。
|
||||||
|
|
||||||
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`
|
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`;本机 `.env` 里写的是
|
||||||
|
绝对路径,直接就能看到当前实际连的库
|
||||||
- `CORS_ORIGINS`:允许访问的前端地址
|
- `CORS_ORIGINS`:允许访问的前端地址
|
||||||
- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建
|
- `OPENAI_API_KEY`:可选。只在初始化(`python -m seed`)时用来创建
|
||||||
“OpenAI · gpt-4o-mini”默认通道并使用该 Key;
|
“OpenAI · gpt-4o-mini”默认通道;正常使用的模型通道请在「模型配置」里维护,
|
||||||
`ai_teacher.py` 的逐题讲解仍为本地规则实现
|
`ai_teacher.py` 的逐题讲解仍为本地规则实现
|
||||||
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置
|
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置(改后所有人需要重新登录)
|
||||||
|
|
||||||
|
**换库**:停掉后端 → 覆盖 `data/math.db` → 重启。启动只会建表 / 补列,
|
||||||
|
不会写入任何业务数据,覆盖回来的库保持原样。
|
||||||
|
|
||||||
## 重置演示数据
|
## 重置演示数据
|
||||||
|
|
||||||
|
|
@ -335,5 +344,6 @@ cd backend
|
||||||
或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。
|
或 `./scripts/db.sh load data/dump/full.sql`(整库)→ 重启后端。
|
||||||
* 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。
|
* 想确认远端有没有库快照:`git log --oneline -- data/dump` 有记录才说明推上去了。
|
||||||
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
|
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
|
||||||
若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书
|
若库丢了,初始化时的 `seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的
|
||||||
(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
|
已知电子书(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。
|
||||||
|
(这一步只在 `python -m seed` 时执行,后端启动不再改动库与电子书文件。)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
"""SQLite 数据库连接与会话管理。"""
|
"""SQLite 数据库连接与会话管理。
|
||||||
|
|
||||||
|
配置来源(优先级从高到低):进程环境变量 → 仓库根 ``.env`` →
|
||||||
|
``backend/.env`` → 代码里的默认值。``.env`` 在本模块导入时即被读入,
|
||||||
|
因此 ``DATABASE_URL`` / ``CORS_ORIGINS`` / ``JWT_SECRET`` 等都在此生效。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event
|
||||||
|
|
@ -11,6 +17,31 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DEFAULT_DB_PATH = REPO_ROOT / "data" / "math.db"
|
DEFAULT_DB_PATH = REPO_ROOT / "data" / "math.db"
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_files() -> None:
|
||||||
|
"""读取 ``.env``:仓库根优先,``backend/.env`` 只补前者没有的键。
|
||||||
|
|
||||||
|
已经存在的环境变量不会被覆盖,所以容器/脚本用 ``DATABASE_URL=…``
|
||||||
|
直接启动时仍然以环境变量为准。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
except ImportError:
|
||||||
|
# 不静默:否则 .env 会被“看起来没生效”地忽略掉
|
||||||
|
print(
|
||||||
|
"[config] 未安装 python-dotenv,.env 不会生效。"
|
||||||
|
"请执行:backend/.venv/bin/pip install -r backend/requirements.txt",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for path in (REPO_ROOT / ".env", BACKEND_DIR / ".env"):
|
||||||
|
if path.is_file():
|
||||||
|
load_dotenv(path, override=False)
|
||||||
|
|
||||||
|
|
||||||
|
_load_env_files()
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|
@ -41,7 +72,12 @@ if DATABASE_URL.startswith("sqlite"):
|
||||||
|
|
||||||
|
|
||||||
def ensure_schema() -> None:
|
def ensure_schema() -> None:
|
||||||
"""建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。"""
|
"""建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。
|
||||||
|
|
||||||
|
只做**表与字段**层面的对齐,不写任何业务数据:后端每次启动都可以安全
|
||||||
|
重放。种子数据与历史数据迁移只在初始化时执行 ——
|
||||||
|
``cd backend && .venv/bin/python -m seed``。
|
||||||
|
"""
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
|
@ -114,14 +150,13 @@ def ensure_schema() -> None:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
|
|
||||||
_split_courses()
|
|
||||||
|
|
||||||
|
def migrate_legacy_courses() -> None:
|
||||||
def _split_courses() -> None:
|
|
||||||
"""把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。
|
"""把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。
|
||||||
|
|
||||||
教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。
|
教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。
|
||||||
|
属于数据迁移,只在 ``python -m seed`` 初始化时执行;调用前需先跑
|
||||||
|
``ensure_schema()``(要用到新补的 ``knowledge_resources.course_id``)。
|
||||||
幂等:kind 列不存在(已迁移过)时直接返回。
|
幂等:kind 列不存在(已迁移过)时直接返回。
|
||||||
"""
|
"""
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
"""数学学习系统后端入口。"""
|
"""数学学习系统后端入口。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from database import REPO_ROOT, SessionLocal, ensure_schema
|
from database import REPO_ROOT, ensure_schema
|
||||||
from routers import (
|
from routers import (
|
||||||
analytics,
|
analytics,
|
||||||
archive,
|
archive,
|
||||||
|
|
@ -25,71 +23,13 @@ from routers import (
|
||||||
question_bank,
|
question_bank,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
from seed.catalog import ensure_catalog
|
|
||||||
from services import ebooks as ebook_store
|
|
||||||
from seed.ebooks import ensure_ebooks
|
|
||||||
from seed.knowledge_graph import ensure_knowledge_graph
|
|
||||||
from seed.rbac import ensure_rbac
|
|
||||||
from services.learning_profile import (
|
|
||||||
ensure_default_profiles,
|
|
||||||
remove_unused_admin_profile,
|
|
||||||
user_has_admin_role,
|
|
||||||
)
|
|
||||||
from services.llm_config import ensure_llm_channels
|
|
||||||
from models import AttemptItem, ErrorEntry, Question, User
|
|
||||||
from services.knowledge_service import set_question_knowledge_names
|
|
||||||
|
|
||||||
|
|
||||||
|
# 启动只对齐表结构(建表 / 补列),不写任何业务数据:
|
||||||
|
# 换成自己准备好的 math.db 后,重启即原样使用,不会被种子改动。
|
||||||
|
# 种子数据与历史数据迁移只在初始化时执行:
|
||||||
|
# cd backend && .venv/bin/python -m seed [--force]
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
ensure_rbac(db)
|
|
||||||
ensure_knowledge_graph(db)
|
|
||||||
ensure_default_profiles(db)
|
|
||||||
for user in db.query(User).all():
|
|
||||||
if user_has_admin_role(db, user.id):
|
|
||||||
remove_unused_admin_profile(db, user.id)
|
|
||||||
ensure_catalog(db)
|
|
||||||
ensure_ebooks(db)
|
|
||||||
ebook_store.backfill_pages(db)
|
|
||||||
ensure_llm_channels(db)
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"UPDATE questions SET used_count = ("
|
|
||||||
"SELECT COUNT(*) FROM attempt_items "
|
|
||||||
"WHERE attempt_items.question_id = questions.id"
|
|
||||||
") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items "
|
|
||||||
"WHERE question_id IS NOT NULL)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for question in db.query(Question).all():
|
|
||||||
has_links = db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT 1 FROM question_knowledge "
|
|
||||||
"WHERE question_id = :qid LIMIT 1"
|
|
||||||
),
|
|
||||||
{"qid": question.id},
|
|
||||||
).first()
|
|
||||||
if has_links is None:
|
|
||||||
set_question_knowledge_names(
|
|
||||||
db,
|
|
||||||
question,
|
|
||||||
[question.knowledge_name],
|
|
||||||
attach_to_chapter=True,
|
|
||||||
)
|
|
||||||
for row in db.query(ErrorEntry).all():
|
|
||||||
if not row.knowledge_names or row.knowledge_names == "[]":
|
|
||||||
row.knowledge_names = json.dumps(
|
|
||||||
[row.knowledge_name], ensure_ascii=False
|
|
||||||
)
|
|
||||||
for row in db.query(AttemptItem).all():
|
|
||||||
if not row.knowledge_names or row.knowledge_names == "[]":
|
|
||||||
row.knowledge_names = json.dumps(
|
|
||||||
[row.knowledge_name], ensure_ascii=False
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _cors_origins() -> list[str]:
|
def _cors_origins() -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ fastapi>=0.115,<1.0
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
sqlalchemy>=2.0,<3.0
|
sqlalchemy>=2.0,<3.0
|
||||||
pydantic>=2.7,<3.0
|
pydantic>=2.7,<3.0
|
||||||
|
python-dotenv>=1.0,<2.0
|
||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
pyjwt>=2.8,<3.0
|
pyjwt>=2.8,<3.0
|
||||||
python-multipart>=0.0.20
|
python-multipart>=0.0.20
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from datetime import date, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import SessionLocal, get_db
|
||||||
from dependencies import require_learning_user
|
from dependencies import require_learning_user
|
||||||
from models import (
|
from models import (
|
||||||
AttemptItem,
|
AttemptItem,
|
||||||
|
|
@ -113,20 +113,8 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict:
|
||||||
)
|
)
|
||||||
if completion and completion.chapter_id is not None:
|
if completion and completion.chapter_id is not None:
|
||||||
chapter = db.get(Chapter, completion.chapter_id)
|
chapter = db.get(Chapter, completion.chapter_id)
|
||||||
textbook = (
|
if chapter is not None:
|
||||||
db.get(Textbook, chapter.textbook_id) if chapter is not None else None
|
return _completed_context(db, chapter, completion.rating)
|
||||||
)
|
|
||||||
return {
|
|
||||||
"chapter_id": chapter.id if chapter else None,
|
|
||||||
"chapter_name": chapter.name if chapter else "",
|
|
||||||
"book_name": textbook.name if textbook else "",
|
|
||||||
"rating": completion.rating,
|
|
||||||
"knowledge_names": chapter_knowledge_names(db, chapter.id)
|
|
||||||
if chapter
|
|
||||||
else [],
|
|
||||||
"reason": "今日章节已完成",
|
|
||||||
**_ebook_ref(db, chapter.id if chapter else None),
|
|
||||||
}
|
|
||||||
recommendation = build_daily_recommendation(db, user_id) or {}
|
recommendation = build_daily_recommendation(db, user_id) or {}
|
||||||
return {
|
return {
|
||||||
"chapter_id": recommendation.get("chapter_id"),
|
"chapter_id": recommendation.get("chapter_id"),
|
||||||
|
|
@ -139,6 +127,25 @@ def _daily_context(db: Session, user_id: int, today: date) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _completed_context(db: Session, chapter: Chapter, rating: str) -> dict:
|
||||||
|
"""今日已完成时的上下文。
|
||||||
|
|
||||||
|
直接把刚写完的章节与自评拼出来,不再依赖「写完立刻回读」——
|
||||||
|
复用同一个连接时 SQLite 偶尔还会停在写入前的快照上,
|
||||||
|
响应里就会出现「已完成但自评是空的」这种自相矛盾的结果。
|
||||||
|
"""
|
||||||
|
textbook = db.get(Textbook, chapter.textbook_id)
|
||||||
|
return {
|
||||||
|
"chapter_id": chapter.id,
|
||||||
|
"chapter_name": chapter.name,
|
||||||
|
"book_name": textbook.name if textbook else "",
|
||||||
|
"rating": rating,
|
||||||
|
"knowledge_names": chapter_knowledge_names(db, chapter.id),
|
||||||
|
"reason": "今日章节已完成",
|
||||||
|
**_ebook_ref(db, chapter.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _streak(done_dates: set[date]) -> int:
|
def _streak(done_dates: set[date]) -> int:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
if today not in done_dates and today - timedelta(days=1) not in done_dates:
|
if today not in done_dates and today - timedelta(days=1) not in done_dates:
|
||||||
|
|
@ -413,15 +420,23 @@ def complete_daily(
|
||||||
exists.chapter_id = chapter.id
|
exists.chapter_id = chapter.id
|
||||||
exists.rating = payload.rating
|
exists.rating = payload.rating
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# 写完用新会话回读连续天数:同一个 session 复用连接时可能读到写入前的快照,
|
||||||
|
# 会让响应里的连续天数与自评对不上(前端就会显示成提交没生效)。
|
||||||
|
read_db = SessionLocal()
|
||||||
|
try:
|
||||||
done_dates = {
|
done_dates = {
|
||||||
row.done_on
|
row.done_on
|
||||||
for row in db.query(DailyCompletion)
|
for row in read_db.query(DailyCompletion)
|
||||||
.filter(DailyCompletion.user_id == user.id)
|
.filter(DailyCompletion.user_id == user.id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
read_db.close()
|
||||||
|
# 今天这一条就是本次写入的,不依赖回读结果
|
||||||
|
done_dates.add(today)
|
||||||
return DailyOut(
|
return DailyOut(
|
||||||
today=today,
|
today=today,
|
||||||
completed=True,
|
completed=True,
|
||||||
streak=_streak(done_dates),
|
streak=_streak(done_dates),
|
||||||
**_daily_context(db, user.id, today),
|
**_completed_context(db, chapter, payload.rating),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from dependencies import require_permission
|
from dependencies import require_learning_user, require_permission
|
||||||
from models import (
|
from models import (
|
||||||
ChapterKnowledge,
|
ChapterKnowledge,
|
||||||
Course,
|
Course,
|
||||||
|
|
@ -22,12 +22,15 @@ from schemas import (
|
||||||
KnowledgeGraphEdge,
|
KnowledgeGraphEdge,
|
||||||
KnowledgeGraphNode,
|
KnowledgeGraphNode,
|
||||||
KnowledgeGraphOut,
|
KnowledgeGraphOut,
|
||||||
|
KnowledgeMapNode,
|
||||||
|
KnowledgeMapOut,
|
||||||
KnowledgeNodeCreate,
|
KnowledgeNodeCreate,
|
||||||
KnowledgeNodeUpdate,
|
KnowledgeNodeUpdate,
|
||||||
KnowledgeRelationIn,
|
KnowledgeRelationIn,
|
||||||
KnowledgeResourceIn,
|
KnowledgeResourceIn,
|
||||||
KnowledgeResourceOut,
|
KnowledgeResourceOut,
|
||||||
)
|
)
|
||||||
|
from services.mastery_engine import status_for
|
||||||
from services.knowledge_service import knowledge_resources
|
from services.knowledge_service import knowledge_resources
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,6 +101,69 @@ def get_graph(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/map", response_model=KnowledgeMapOut)
|
||||||
|
def get_knowledge_map(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_learning_user),
|
||||||
|
):
|
||||||
|
"""学生视角的完整知识图谱:全量结构 + 本人掌握度。
|
||||||
|
|
||||||
|
/knowledge 只返回练过的知识点,画不出一张完整的地图;
|
||||||
|
这里一次给全量节点(含未练过的)与全部关系,计数用分组查询,避免逐点 N+1。
|
||||||
|
"""
|
||||||
|
nodes = (
|
||||||
|
db.query(Knowledge)
|
||||||
|
.order_by(Knowledge.domain, Knowledge.category, Knowledge.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all()
|
||||||
|
mastery = {
|
||||||
|
row.knowledge_id: row.mastery
|
||||||
|
for row in db.query(UserKnowledge)
|
||||||
|
.filter(UserKnowledge.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
question_counts = dict(
|
||||||
|
db.query(QuestionKnowledge.knowledge_id, func.count(QuestionKnowledge.id))
|
||||||
|
.group_by(QuestionKnowledge.knowledge_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
chapter_counts = dict(
|
||||||
|
db.query(ChapterKnowledge.knowledge_id, func.count(ChapterKnowledge.id))
|
||||||
|
.group_by(ChapterKnowledge.knowledge_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def count(mapping: dict, node_id: int) -> int:
|
||||||
|
return int(mapping.get(node_id, 0) or 0)
|
||||||
|
|
||||||
|
return KnowledgeMapOut(
|
||||||
|
nodes=[
|
||||||
|
KnowledgeMapNode(
|
||||||
|
id=node.id,
|
||||||
|
name=node.name,
|
||||||
|
domain=node.domain,
|
||||||
|
category=node.category,
|
||||||
|
description=node.description,
|
||||||
|
mastery=mastery.get(node.id, 0),
|
||||||
|
status=status_for(mastery.get(node.id, 0)),
|
||||||
|
practiced=node.id in mastery,
|
||||||
|
question_count=count(question_counts, node.id),
|
||||||
|
chapter_count=count(chapter_counts, node.id),
|
||||||
|
)
|
||||||
|
for node in nodes
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
KnowledgeGraphEdge(
|
||||||
|
source=edge.source_id,
|
||||||
|
target=edge.target_id,
|
||||||
|
relation_type=edge.relation_type,
|
||||||
|
)
|
||||||
|
for edge in edges
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/nodes", response_model=KnowledgeGraphNode)
|
@router.post("/nodes", response_model=KnowledgeGraphNode)
|
||||||
def create_node(
|
def create_node(
|
||||||
payload: KnowledgeNodeCreate,
|
payload: KnowledgeNodeCreate,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
|
@ -19,7 +20,12 @@ from models import (
|
||||||
Textbook,
|
Textbook,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate
|
from schemas import (
|
||||||
|
BankSummaryOut,
|
||||||
|
GenerateIn,
|
||||||
|
QuestionBankItemOut,
|
||||||
|
QuestionUpdate,
|
||||||
|
)
|
||||||
from services.question_generator import generate_questions
|
from services.question_generator import generate_questions
|
||||||
from services.llm_config import get_default_channel
|
from services.llm_config import get_default_channel
|
||||||
from services.knowledge_service import (
|
from services.knowledge_service import (
|
||||||
|
|
@ -141,6 +147,27 @@ def list_questions(
|
||||||
return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows]
|
return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=BankSummaryOut)
|
||||||
|
def summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_permission("question-bank:manage")),
|
||||||
|
):
|
||||||
|
"题库侧栏计数:总数、未挂章节、AI 生成、已被使用,一次算完。"
|
||||||
|
|
||||||
|
def count(*conditions) -> int:
|
||||||
|
query = db.query(func.count(Question.id))
|
||||||
|
for condition in conditions:
|
||||||
|
query = query.filter(condition)
|
||||||
|
return int(query.scalar() or 0)
|
||||||
|
|
||||||
|
return BankSummaryOut(
|
||||||
|
total=count(),
|
||||||
|
unattached=count(Question.chapter_id.is_(None)),
|
||||||
|
generated=count(Question.is_generated.is_(True)),
|
||||||
|
used=count(Question.used_count > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/questions/{question_id}")
|
@router.delete("/questions/{question_id}")
|
||||||
def delete_question(
|
def delete_question(
|
||||||
question_id: int,
|
question_id: int,
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,14 @@ class QuestionBankItemOut(BaseModel):
|
||||||
knowledge_ids: list[int] = []
|
knowledge_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BankSummaryOut(BaseModel):
|
||||||
|
"侧栏计数用:全库题目总数与几个切片,避免把整库题目拉回前端数数。"
|
||||||
|
total: int
|
||||||
|
unattached: int
|
||||||
|
generated: int
|
||||||
|
used: int
|
||||||
|
|
||||||
|
|
||||||
class QuestionUpdate(BaseModel):
|
class QuestionUpdate(BaseModel):
|
||||||
stem: str = Field(min_length=1)
|
stem: str = Field(min_length=1)
|
||||||
options: list[str] = Field(min_length=4, max_length=4)
|
options: list[str] = Field(min_length=4, max_length=4)
|
||||||
|
|
@ -541,6 +549,26 @@ class KnowledgeGraphOut(BaseModel):
|
||||||
edges: list[KnowledgeGraphEdge] = []
|
edges: list[KnowledgeGraphEdge] = []
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeMapNode(BaseModel):
|
||||||
|
"""学生视角的知识节点:结构 + 本人掌握度,一次给全量。"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
category: str
|
||||||
|
description: str = ""
|
||||||
|
mastery: int = 0
|
||||||
|
status: str = "重点"
|
||||||
|
# 有没有练过:没练过的不该被当成「薄弱」,界面上单独表达
|
||||||
|
practiced: bool = False
|
||||||
|
chapter_count: int = 0
|
||||||
|
question_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeMapOut(BaseModel):
|
||||||
|
nodes: list[KnowledgeMapNode] = []
|
||||||
|
edges: list[KnowledgeGraphEdge] = []
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeNodeCreate(BaseModel):
|
class KnowledgeNodeCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=64)
|
name: str = Field(min_length=1, max_length=64)
|
||||||
domain: str = "初等"
|
domain: str = "初等"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
用法:在 backend/ 目录下执行 python -m seed [--force]
|
用法:在 backend/ 目录下执行 python -m seed [--force]
|
||||||
--force 会清空全部学习数据与题库后重新导入,保留用户与角色。
|
--force 会清空全部学习数据与题库后重新导入,保留用户与角色。
|
||||||
|
|
||||||
|
这里是**唯一**会补种数据的地方:后端每次启动只对齐表结构
|
||||||
|
(``database.ensure_schema``),不再补章节 / 知识点 / 教材等业务数据,
|
||||||
|
因此自己准备的 math.db 覆盖回来后不会被种子改动。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -9,7 +13,14 @@ import argparse
|
||||||
import json
|
import json
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from database import DATABASE_URL, SessionLocal, ensure_schema
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import (
|
||||||
|
DATABASE_URL,
|
||||||
|
SessionLocal,
|
||||||
|
ensure_schema,
|
||||||
|
migrate_legacy_courses,
|
||||||
|
)
|
||||||
from models import (
|
from models import (
|
||||||
AttemptItem,
|
AttemptItem,
|
||||||
AttemptSession,
|
AttemptSession,
|
||||||
|
|
@ -22,7 +33,9 @@ from models import (
|
||||||
from seed.catalog import ensure_catalog
|
from seed.catalog import ensure_catalog
|
||||||
from seed.data import KNOWLEDGE, QUESTIONS
|
from seed.data import KNOWLEDGE, QUESTIONS
|
||||||
from seed.ebooks import ensure_ebooks
|
from seed.ebooks import ensure_ebooks
|
||||||
|
from seed.knowledge_graph import ensure_knowledge_graph
|
||||||
from seed.rbac import DEFAULT_USERS, ensure_rbac
|
from seed.rbac import DEFAULT_USERS, ensure_rbac
|
||||||
|
from services import ebooks as ebook_store
|
||||||
from services.learning_profile import (
|
from services.learning_profile import (
|
||||||
ensure_default_profiles,
|
ensure_default_profiles,
|
||||||
remove_unused_admin_profile,
|
remove_unused_admin_profile,
|
||||||
|
|
@ -116,12 +129,58 @@ def _demo_history(db, student: User) -> None:
|
||||||
db.add(DailyCompletion(user_id=student.id, done_on=date.today() - timedelta(days=1)))
|
db.add(DailyCompletion(user_id=student.id, done_on=date.today() - timedelta(days=1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_derived_fields(db) -> None:
|
||||||
|
"""给存量数据补派生字段(题目使用次数、知识点名快照)。
|
||||||
|
|
||||||
|
以前这些 UPDATE 挂在后端启动流程里,现在只属于初始化:
|
||||||
|
自己准备好的库覆盖回来后不会被重新计算 / 覆盖。
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE questions SET used_count = ("
|
||||||
|
"SELECT COUNT(*) FROM attempt_items "
|
||||||
|
"WHERE attempt_items.question_id = questions.id"
|
||||||
|
") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items "
|
||||||
|
"WHERE question_id IS NOT NULL)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for question in db.query(Question).all():
|
||||||
|
has_links = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT 1 FROM question_knowledge "
|
||||||
|
"WHERE question_id = :qid LIMIT 1"
|
||||||
|
),
|
||||||
|
{"qid": question.id},
|
||||||
|
).first()
|
||||||
|
if has_links is None:
|
||||||
|
set_question_knowledge_names(
|
||||||
|
db,
|
||||||
|
question,
|
||||||
|
[question.knowledge_name],
|
||||||
|
attach_to_chapter=True,
|
||||||
|
)
|
||||||
|
for row in db.query(ErrorEntry).all():
|
||||||
|
if not row.knowledge_names or row.knowledge_names == "[]":
|
||||||
|
row.knowledge_names = json.dumps(
|
||||||
|
[row.knowledge_name], ensure_ascii=False
|
||||||
|
)
|
||||||
|
for row in db.query(AttemptItem).all():
|
||||||
|
if not row.knowledge_names or row.knowledge_names == "[]":
|
||||||
|
row.knowledge_names = json.dumps(
|
||||||
|
[row.knowledge_name], ensure_ascii=False
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def load_seed(force: bool = False) -> bool:
|
def load_seed(force: bool = False) -> bool:
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
|
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
|
||||||
|
migrate_legacy_courses()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
created = False
|
created = False
|
||||||
try:
|
try:
|
||||||
ensure_rbac(db)
|
ensure_rbac(db)
|
||||||
|
ensure_knowledge_graph(db)
|
||||||
users = {
|
users = {
|
||||||
username: db.query(User).filter(User.username == username).first()
|
username: db.query(User).filter(User.username == username).first()
|
||||||
for username, _, _, _ in DEFAULT_USERS
|
for username, _, _, _ in DEFAULT_USERS
|
||||||
|
|
@ -164,6 +223,8 @@ def load_seed(force: bool = False) -> bool:
|
||||||
ensure_catalog(db, force_sync=force)
|
ensure_catalog(db, force_sync=force)
|
||||||
# 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好
|
# 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好
|
||||||
ensure_ebooks(db)
|
ensure_ebooks(db)
|
||||||
|
ebook_store.backfill_pages(db)
|
||||||
|
_backfill_derived_fields(db)
|
||||||
for user in users.values():
|
for user in users.values():
|
||||||
if user and user_has_admin_role(db, user.id):
|
if user and user_has_admin_role(db, user.id):
|
||||||
remove_unused_admin_profile(db, user.id)
|
remove_unused_admin_profile(db, user.id)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>数学学习系统</title>
|
<title>数学学习系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -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":
|
case "errors":
|
||||||
return <Errors />;
|
return <Errors />;
|
||||||
case "records":
|
case "records":
|
||||||
return <Records />;
|
return <Records onNavigate={navigate} />;
|
||||||
case "daily":
|
case "daily":
|
||||||
return (
|
return (
|
||||||
<Daily
|
<Daily
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
Overview,
|
Overview,
|
||||||
PracticeSession,
|
PracticeSession,
|
||||||
QuestionBankItem,
|
QuestionBankItem,
|
||||||
|
QuestionBankSummary,
|
||||||
QuestionUpdatePayload,
|
QuestionUpdatePayload,
|
||||||
RecordDetail,
|
RecordDetail,
|
||||||
RecordItem,
|
RecordItem,
|
||||||
|
|
@ -36,6 +37,7 @@ import type {
|
||||||
KnowledgeGraphData,
|
KnowledgeGraphData,
|
||||||
KnowledgeGraphEdge,
|
KnowledgeGraphEdge,
|
||||||
KnowledgeGraphNode,
|
KnowledgeGraphNode,
|
||||||
|
KnowledgeMapData,
|
||||||
KnowledgeResourceLink,
|
KnowledgeResourceLink,
|
||||||
KnowledgeNodeCreatePayload,
|
KnowledgeNodeCreatePayload,
|
||||||
KnowledgeNodeUpdatePayload,
|
KnowledgeNodeUpdatePayload,
|
||||||
|
|
@ -335,6 +337,8 @@ export const api = {
|
||||||
`/api/question-bank/questions${query ? `?${query}` : ""}`,
|
`/api/question-bank/questions${query ? `?${query}` : ""}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
bankSummary: () =>
|
||||||
|
request<QuestionBankSummary>("/api/question-bank/summary"),
|
||||||
deleteQuestion: (id: number) =>
|
deleteQuestion: (id: number) =>
|
||||||
request<{ deleted: number }>(`/api/question-bank/questions/${id}`, {
|
request<{ deleted: number }>(`/api/question-bank/questions/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|
@ -355,6 +359,7 @@ export const api = {
|
||||||
request<LlmTaskResult>(`/api/llm/tasks/${id}`),
|
request<LlmTaskResult>(`/api/llm/tasks/${id}`),
|
||||||
knowledgeGraph: () =>
|
knowledgeGraph: () =>
|
||||||
request<KnowledgeGraphData>("/api/knowledge/graph"),
|
request<KnowledgeGraphData>("/api/knowledge/graph"),
|
||||||
|
knowledgeMap: () => request<KnowledgeMapData>("/api/knowledge/map"),
|
||||||
createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) =>
|
createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) =>
|
||||||
request<KnowledgeGraphNode>("/api/knowledge/nodes", {
|
request<KnowledgeGraphNode>("/api/knowledge/nodes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={`${option}-${index}`}
|
key={`${option}-${index}`}
|
||||||
type={selected ? "primary" : "default"}
|
|
||||||
block
|
block
|
||||||
className={`option-row${selected ? " option-row--active" : ""}`}
|
className={`option-row${selected ? " option-row--active" : ""}`}
|
||||||
onClick={() => onChange(index)}
|
onClick={() => onChange(index)}
|
||||||
>
|
>
|
||||||
<span className="option-row-letter">{optionLetter(index)}.</span>
|
<span className="option-row-letter">{optionLetter(index)}.</span>
|
||||||
<RichText content={option} />
|
<RichText content={option} />
|
||||||
|
{selected && <CheckCircleFilled className="option-row-check" />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 type { PracticeSession, SubmitResult } from "../types";
|
||||||
|
import { AnswerCompare, AnswerSheet, PaperQuestion, scrollToQuestion } from "./Paper";
|
||||||
|
import { OptionList, optionLetter } from "./Options";
|
||||||
|
import { Pane, SplitView } from "./SplitView";
|
||||||
import RichText from "./RichText";
|
import RichText from "./RichText";
|
||||||
import { DataRow, DataRows, Panel, SectionTitle } from "./ui";
|
|
||||||
import { status, tagColor } from "../theme";
|
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
|
import { masteryTagColor, tagColor } from "../theme";
|
||||||
|
|
||||||
interface PracticeResultProps {
|
interface PracticeResultProps {
|
||||||
session: PracticeSession;
|
session: PracticeSession;
|
||||||
|
|
@ -25,53 +29,164 @@ export function PracticeResult({
|
||||||
onExit,
|
onExit,
|
||||||
}: PracticeResultProps) {
|
}: PracticeResultProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const reviews = result.per_question.map((item) => ({
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
...item,
|
|
||||||
|
const questions = useMemo(
|
||||||
|
() =>
|
||||||
|
result.per_question.map((item, index) => ({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
question: session.questions.find((q) => q.id === item.question_id),
|
question: session.questions.find((q) => q.id === item.question_id),
|
||||||
}));
|
})),
|
||||||
|
[result.per_question, session.questions],
|
||||||
|
);
|
||||||
|
const wrong = questions.filter(({ item }) => !item.correct);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<SplitView
|
||||||
<Alert
|
side="right"
|
||||||
type={result.score === result.total ? "success" : "info"}
|
storageKey="nex_math.split.test.aside"
|
||||||
showIcon
|
defaultWidth={286}
|
||||||
title={t("result.score", { score: result.score, total: result.total })}
|
aside={
|
||||||
description={t("result.summary", { accuracy: result.accuracy })}
|
<Pane
|
||||||
|
title={t("result.sheet")}
|
||||||
|
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={
|
footer={
|
||||||
<Space wrap size={[4, 4]}>
|
<Flex vertical gap={8}>
|
||||||
{review.question && <RichText content={review.question.stem} />}
|
<Button
|
||||||
<Tag color={review.correct ? tagColor.positive : tagColor.negative}>
|
type="primary"
|
||||||
{review.correct ? t("result.correct") : t("result.wrong")}
|
block
|
||||||
</Tag>
|
icon={<ReloadOutlined />}
|
||||||
</Space>
|
onClick={onRestart}
|
||||||
}
|
>
|
||||||
description={<RichText content={review.explanation} />}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DataRows>
|
|
||||||
<Flex gap={10} style={{ marginTop: 16 }}>
|
|
||||||
<Button type="primary" icon={<ReloadOutlined />} onClick={onRestart}>
|
|
||||||
{t("result.retry")}
|
{t("result.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<DashboardOutlined />} onClick={onExit}>
|
<Button block icon={<DashboardOutlined />} onClick={onExit}>
|
||||||
{t("result.back")}
|
{t("result.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</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 { api } from "../api/client";
|
||||||
import type { PracticeSession, SubmitResult } from "../types";
|
import type { PracticeSession, SubmitResult } from "../types";
|
||||||
import { Panel, useUi } from "./ui";
|
import { Pane, SplitView } from "./SplitView";
|
||||||
|
import { AnswerSheet, PaperQuestion, scrollToQuestion, useActiveAnchor } from "./Paper";
|
||||||
import { OptionPicker } from "./Options";
|
import { OptionPicker } from "./Options";
|
||||||
import { PracticeResult } from "./PracticeResult";
|
import { PracticeResult } from "./PracticeResult";
|
||||||
|
import { EmptyState, useUi } from "./ui";
|
||||||
import RichText from "./RichText";
|
import RichText from "./RichText";
|
||||||
import { tagColor } from "../theme";
|
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
|
|
||||||
interface QuestionFlowProps {
|
interface QuestionFlowProps {
|
||||||
|
|
@ -22,7 +33,7 @@ export default function QuestionFlow({
|
||||||
}: QuestionFlowProps) {
|
}: QuestionFlowProps) {
|
||||||
const { message } = useUi();
|
const { message } = useUi();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [index, setIndex] = useState(0);
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [answers, setAnswers] = useState<(number | undefined)[]>(
|
const [answers, setAnswers] = useState<(number | undefined)[]>(
|
||||||
session.questions.map(() => undefined),
|
session.questions.map(() => undefined),
|
||||||
);
|
);
|
||||||
|
|
@ -30,8 +41,18 @@ export default function QuestionFlow({
|
||||||
const [result, setResult] = useState<SubmitResult | null>(null);
|
const [result, setResult] = useState<SubmitResult | null>(null);
|
||||||
|
|
||||||
const total = session.questions.length;
|
const total = session.questions.length;
|
||||||
const question = session.questions[index];
|
const empty = total === 0;
|
||||||
const progress = total ? Math.round((index / total) * 100) : 0;
|
const answered = answers.filter((value) => value !== undefined).length;
|
||||||
|
const progress = total ? Math.round((answered / total) * 100) : 0;
|
||||||
|
const keys = useMemo(() => session.questions.map((_, i) => i), [session.questions]);
|
||||||
|
const active = useActiveAnchor(bodyRef, keys, answered);
|
||||||
|
/** 当前滚动到的题(答题卡上高亮的那一道),用来在侧栏显示“正在做第几题”。 */
|
||||||
|
const activeIndex = active === null ? 0 : Number(active);
|
||||||
|
const activeAnswer = answers[activeIndex];
|
||||||
|
const activeAnswered = activeAnswer !== undefined;
|
||||||
|
const unansweredIndexes = answers
|
||||||
|
.map((value, index) => (value === undefined ? index : -1))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,28 +65,33 @@ export default function QuestionFlow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function choose(optionIndex: number) {
|
function choose(questionIndex: number, optionIndex: number) {
|
||||||
setAnswers((prev) => {
|
setAnswers((prev) => {
|
||||||
const next = prev.slice();
|
const next = prev.slice();
|
||||||
next[index] = optionIndex;
|
next[questionIndex] = optionIndex;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jumpTo(key: string | number) {
|
||||||
|
scrollToQuestion(bodyRef.current, key);
|
||||||
|
}
|
||||||
|
|
||||||
async function finish() {
|
async function finish() {
|
||||||
if (answers.some((value) => value === undefined)) {
|
if (empty) return;
|
||||||
message.warning(t("flow.unfinished"));
|
const firstMissing = answers.findIndex((value) => value === undefined);
|
||||||
|
if (firstMissing >= 0) {
|
||||||
|
message.warning(t("flow.unfinishedAt", { index: firstMissing + 1 }));
|
||||||
|
jumpTo(firstMissing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const payload = session.questions.map((q, i) => ({
|
const payload = session.questions.map((question, i) => ({
|
||||||
question_id: q.id,
|
question_id: question.id,
|
||||||
selected: answers[i] as number,
|
selected: answers[i] as number,
|
||||||
}));
|
}));
|
||||||
setResult(
|
setResult(await api.submit(session.mode, payload, session.chapter_id));
|
||||||
await api.submit(session.mode, payload, session.chapter_id),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err instanceof Error ? err.message : t("flow.submitFailed"));
|
message.error(err instanceof Error ? err.message : t("flow.submitFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -73,45 +99,151 @@ export default function QuestionFlow({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!question) return null;
|
|
||||||
|
|
||||||
const isLast = index === total - 1;
|
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<SplitView
|
||||||
<Flex align="center" justify="space-between" gap={12} style={{ marginBottom: 8 }}>
|
side="right"
|
||||||
<Tag color={tagColor.knowledge}>{session.title}</Tag>
|
storageKey="nex_math.split.test.aside"
|
||||||
<Typography.Text type="secondary">
|
defaultWidth={268}
|
||||||
{t("flow.progressPrefix")} <b>{index + 1}</b> / {total}{" "}
|
aside={
|
||||||
{t("flow.progressSuffix")}
|
<Pane
|
||||||
|
title={t("flow.sheet")}
|
||||||
|
extra={
|
||||||
|
!empty && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t("flow.answered", { answered, total })}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
)
|
||||||
<Progress percent={progress} showInfo={false} />
|
}
|
||||||
<div className="question-stem">
|
toolbar={
|
||||||
<RichText content={question.stem} />
|
empty ? (
|
||||||
</div>
|
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
|
||||||
<OptionPicker
|
{t("flow.emptyPaperShort")}
|
||||||
options={question.options}
|
</Typography.Text>
|
||||||
value={answers[index]}
|
) : (
|
||||||
onChange={choose}
|
<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")}
|
footer={
|
||||||
</Button>
|
<Flex vertical gap={10}>
|
||||||
{!isLast ? (
|
{!empty && (
|
||||||
|
<Progress
|
||||||
|
percent={progress}
|
||||||
|
showInfo={false}
|
||||||
|
status={progress === 100 ? "success" : "active"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={answers[index] === undefined}
|
block
|
||||||
onClick={() => setIndex((value) => value + 1)}
|
loading={submitting}
|
||||||
|
disabled={empty}
|
||||||
|
onClick={() => void finish()}
|
||||||
>
|
>
|
||||||
{t("flow.next")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="primary" loading={submitting} onClick={finish}>
|
|
||||||
{t("flow.submit")}
|
{t("flow.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!empty && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{progress === 100
|
||||||
|
? t("flow.readyToSubmit")
|
||||||
|
: t("flow.pendingN", { n: total - answered })}
|
||||||
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Flex>
|
||||||
</Panel>
|
}
|
||||||
|
>
|
||||||
|
{!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 右侧,数量统计交给分页条,工具条不再承载按钮。
|
* 主操作按钮一律放在功能页面 header 右侧,数量统计交给分页条,工具条不再承载按钮。
|
||||||
*/
|
*/
|
||||||
export function Toolbar({ filters }: { filters?: ReactNode }) {
|
export function Toolbar({
|
||||||
return <div className="panel-toolbar">{filters}</div>;
|
filters,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
filters?: ReactNode;
|
||||||
|
/** 挤到这一行最右侧(视图切换等) */
|
||||||
|
extra?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="panel-toolbar">
|
||||||
|
{filters}
|
||||||
|
{extra && <div className="panel-toolbar-extra">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 面板内的小节标题。 */
|
/** 面板内的小节标题。 */
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,29 @@ interface ApiState<T> {
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取数钩子。`enabled: false` 时完全不发请求,也不置 loading,
|
||||||
|
* 让页面自己渲染引导空态——缺少前置条件(未选章节、未选教材)时
|
||||||
|
* 不该把「还没开始」渲染成红色的「加载失败」。
|
||||||
|
*/
|
||||||
export function useApi<T>(
|
export function useApi<T>(
|
||||||
load: () => Promise<T>,
|
load: () => Promise<T>,
|
||||||
deps: unknown[] = [],
|
deps: unknown[] = [],
|
||||||
|
options?: { enabled?: boolean },
|
||||||
): ApiState<T> {
|
): ApiState<T> {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
const loaderRef = useRef(load);
|
const loaderRef = useRef(load);
|
||||||
loaderRef.current = load;
|
loaderRef.current = load;
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setData(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -32,12 +44,12 @@ export function useApi<T>(
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [version, ...deps]);
|
}, [version, enabled, ...deps]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
loading: data === null && error === null,
|
loading: enabled && data === null && error === null,
|
||||||
reload: () => setVersion((v) => v + 1),
|
reload: () => setVersion((v) => v + 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,9 +295,12 @@ const zh = {
|
||||||
"daily.nextStep": "下一步",
|
"daily.nextStep": "下一步",
|
||||||
"daily.retest": "再做本章测试",
|
"daily.retest": "再做本章测试",
|
||||||
"daily.openMap": "查看知识地图",
|
"daily.openMap": "查看知识地图",
|
||||||
"chapterTest.selectChapterRequired": "请选择章节",
|
"chapterTest.emptyNoTextbook": "还没有教材,暂时无法按章节组卷。请管理员在「教材管理」中添加教材并整理章节目录。",
|
||||||
|
"chapterTest.emptyNoChapters": "{name} 还没有章节目录。请管理员在「教材管理」里添加章节,并在「题库管理」把题目挂到章节下,之后就能按章节组卷练习。",
|
||||||
|
"chapterTest.emptyPickChapter": "在上方选一章,系统会按你的薄弱知识点组一张卷子。",
|
||||||
"chapterTest.title": "选择测试章节",
|
"chapterTest.title": "选择测试章节",
|
||||||
"chapterTest.reshuffle": "换一套",
|
"chapterTest.reshuffle": "换一套",
|
||||||
|
"chapterTest.countOption": "{n} 题",
|
||||||
"chapterTest.textbookPlaceholder": "选择教材",
|
"chapterTest.textbookPlaceholder": "选择教材",
|
||||||
"chapterTest.chapterPlaceholder": "选择章节",
|
"chapterTest.chapterPlaceholder": "选择章节",
|
||||||
"chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成",
|
"chapterTest.noQuestions": "本章暂无题目,请先在题库管理生成",
|
||||||
|
|
@ -323,8 +326,10 @@ const zh = {
|
||||||
"knowledge.relPrereqOut": "后续知识点",
|
"knowledge.relPrereqOut": "后续知识点",
|
||||||
"knowledge.relRelated": "相关知识点",
|
"knowledge.relRelated": "相关知识点",
|
||||||
"knowledge.masteryTitle": "知识点掌握度",
|
"knowledge.masteryTitle": "知识点掌握度",
|
||||||
|
"knowledge.averageMastery": "平均掌握度",
|
||||||
|
"knowledge.practicedCount": "已练知识点",
|
||||||
|
"knowledge.focusCount": "待重点强化",
|
||||||
"knowledge.bandAll": "全部",
|
"knowledge.bandAll": "全部",
|
||||||
"knowledge.emptyBand": "该档暂无知识点",
|
|
||||||
"knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节",
|
"knowledge.hint": "点击任意知识点查看定义、关联知识点与教材章节",
|
||||||
"knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度",
|
"knowledge.empty": "还没有掌握度数据,完成一次章节测试后会按知识图谱记录掌握度",
|
||||||
"knowledge.detailTitle": "知识点详情",
|
"knowledge.detailTitle": "知识点详情",
|
||||||
|
|
@ -385,13 +390,27 @@ const zh = {
|
||||||
"llm.willSave": "将保存为:{provider} · {model}",
|
"llm.willSave": "将保存为:{provider} · {model}",
|
||||||
|
|
||||||
"graph.nodeTitle": "{name}(章 {chapters} · 题 {questions})",
|
"graph.nodeTitle": "{name}(章 {chapters} · 题 {questions})",
|
||||||
|
"graph.domainAll": "全部领域",
|
||||||
|
"graph.searchPlaceholder": "搜索知识点",
|
||||||
|
"graph.canvasHint": "点知识点看关联;缩放/拖动可调整视图",
|
||||||
|
"graph.zoomIn": "放大",
|
||||||
|
"graph.zoomOut": "缩小",
|
||||||
|
"graph.zoomReset": "还原视图",
|
||||||
|
"graph.emptyDomain": "该领域还没有知识点",
|
||||||
|
"graph.notPracticed": "未练习",
|
||||||
|
"graph.masteryValue": "掌握度 {n}%",
|
||||||
|
"graph.nodeCounts": "题 {questions} · 章 {chapters}",
|
||||||
|
"graph.columnQuestions": "{n} 道题",
|
||||||
|
"graph.edgeLegend": "实线箭头 = 前置(先学箭头尾部的点),虚线 = 相关;点知识点高亮一跳关联",
|
||||||
"graph.domainElementary": "初等数学",
|
"graph.domainElementary": "初等数学",
|
||||||
"graph.domainAdvanced": "高等数学",
|
"graph.domainAdvanced": "高等数学",
|
||||||
"graph.domainPlaceholder": "选择知识领域",
|
|
||||||
"graph.allCategories": "全部(完整图谱)",
|
|
||||||
"graph.add": "新增知识点",
|
"graph.add": "新增知识点",
|
||||||
"graph.addRelation": "添加关系",
|
"graph.addRelation": "添加关系",
|
||||||
"graph.detailTitle": "{name} · 详情",
|
"graph.detailTitle": "{name} · 详情",
|
||||||
|
"graph.detailTitleEmpty": "知识点详情",
|
||||||
|
"graph.detailEmpty": "在上方图谱或树里点一个知识点,这里显示它的定义、关系、关联章节与课程。",
|
||||||
|
"graph.viewGraph": "图谱",
|
||||||
|
"graph.viewTree": "树形",
|
||||||
"graph.fieldDomain": "领域",
|
"graph.fieldDomain": "领域",
|
||||||
"graph.fieldCategory": "分类",
|
"graph.fieldCategory": "分类",
|
||||||
"graph.fieldUsage": "引用",
|
"graph.fieldUsage": "引用",
|
||||||
|
|
@ -433,10 +452,15 @@ const zh = {
|
||||||
"graph.msgUnlinkFailed": "解除失败",
|
"graph.msgUnlinkFailed": "解除失败",
|
||||||
|
|
||||||
"bank.colQuestion": "题目",
|
"bank.colQuestion": "题目",
|
||||||
"bank.colSource": "来源 / 章节",
|
"bank.colChapter": "章节",
|
||||||
"bank.generated": "AI 生成",
|
"bank.chapterTree": "教材 / 章节",
|
||||||
"bank.builtin": "内置题目",
|
"bank.expandAll": "展开全部",
|
||||||
|
"bank.collapseAll": "收起全部",
|
||||||
|
"bank.expandBook": "展开章节",
|
||||||
|
"bank.collapseBook": "收起章节",
|
||||||
|
"bank.noChaptersYet": "暂无章节",
|
||||||
"bank.noChapter": "未挂章节",
|
"bank.noChapter": "未挂章节",
|
||||||
|
"bank.difficultyAll": "难度不限",
|
||||||
"bank.colDifficulty": "难度",
|
"bank.colDifficulty": "难度",
|
||||||
"bank.colUsage": "使用状态",
|
"bank.colUsage": "使用状态",
|
||||||
"bank.usedN": "已使用 {n} 次",
|
"bank.usedN": "已使用 {n} 次",
|
||||||
|
|
@ -720,6 +744,72 @@ const zh = {
|
||||||
"api.taskKindUnsupported": "不支持的任务类型:{kind}",
|
"api.taskKindUnsupported": "不支持的任务类型:{kind}",
|
||||||
"api.knowledgeSomeMissing": "部分知识点不存在",
|
"api.knowledgeSomeMissing": "部分知识点不存在",
|
||||||
"api.ebookFileInvalid": "{name} 不是有效的 {format} 文件",
|
"api.ebookFileInvalid": "{name} 不是有效的 {format} 文件",
|
||||||
|
// —— 分屏试卷工作台(题库 / 记录 / 错题 / 答题共用)——
|
||||||
|
"paper.prevPage": "上一页",
|
||||||
|
"paper.nextPage": "下一页",
|
||||||
|
"paper.pageRange": "第 {start}–{end} 题 / 共 {total} 题",
|
||||||
|
"paper.unanswered": "未作答",
|
||||||
|
"paper.yourAnswer": "我的作答:{answer}",
|
||||||
|
"paper.yourAnswerCorrect": "我的作答:{answer}(正确)",
|
||||||
|
"paper.correctAnswer": "正确答案:{answer}",
|
||||||
|
"paper.score": "得分",
|
||||||
|
"paper.filterAll": "全部 {n} 题",
|
||||||
|
"paper.filterWrong": "错题 {n} 题",
|
||||||
|
"paper.expandExplanations": "展开全部解析",
|
||||||
|
"paper.showExplanation": "查看解析",
|
||||||
|
"paper.emptyPaper": "这份试卷里还没有题目",
|
||||||
|
|
||||||
|
"records.asideTitle": "练习记录",
|
||||||
|
"records.search": "搜索练习名称或日期",
|
||||||
|
"records.stat": "{sessions} 次 · 总正确率 {accuracy}%",
|
||||||
|
"records.summary": "{correct} / {total} 题正确",
|
||||||
|
"records.metaLine": "{date} · 共 {total} 题 · 答对 {correct} 题 · 正确率 {accuracy}%",
|
||||||
|
"records.pickOne": "从左侧选择一次练习,查看逐题明细",
|
||||||
|
"records.startTest": "去做一次章节测试",
|
||||||
|
"records.needsReview": "建议回看",
|
||||||
|
|
||||||
|
"errors.asideTitle": "错题清单",
|
||||||
|
"errors.search": "搜索题干或知识点",
|
||||||
|
"errors.filterKnowledge": "按知识点筛选",
|
||||||
|
"errors.stat": "共 {n} 题",
|
||||||
|
"errors.detailTitle": "错题精讲",
|
||||||
|
"errors.metaLine": "来源:{source} · 记录于 {date} · 诊断:{diagnosis}",
|
||||||
|
"errors.position": "第 {index} / {total} 题",
|
||||||
|
"errors.prev": "上一题",
|
||||||
|
"errors.next": "下一题",
|
||||||
|
|
||||||
|
"bank.asideTitle": "教材与章节",
|
||||||
|
"bank.navAll": "全部题目",
|
||||||
|
"bank.unattachedHint": "其中 {n} 题还没挂到章节",
|
||||||
|
"bank.search": "搜索题干、选项或知识点",
|
||||||
|
"bank.metaLine": "共 {total} 题 · 已使用 {used} 题",
|
||||||
|
"bank.noMatch": "当前筛选条件下没有题目,换个章节或清除筛选",
|
||||||
|
"bank.viewCard": "试卷视图",
|
||||||
|
"bank.viewTable": "表格视图",
|
||||||
|
"bank.usageAll": "使用不限",
|
||||||
|
"bank.filterUsed": "已使用",
|
||||||
|
"bank.filterUnused": "未使用",
|
||||||
|
"bank.difficultyValue": "{stars}",
|
||||||
|
|
||||||
|
"flow.sheet": "答题卡",
|
||||||
|
"flow.answered": "已答 {answered} / {total}",
|
||||||
|
"flow.currentNo": "当前第 {index} 题",
|
||||||
|
"flow.currentPicked": "已选 {letter}",
|
||||||
|
"flow.jumpToUnanswered": "跳到第 {index} 题(未作答)",
|
||||||
|
"flow.answeredAt": "第 {index} 题已选 {letter}",
|
||||||
|
"flow.unanswered": "未作答",
|
||||||
|
"flow.answeredTag": "已作答",
|
||||||
|
"flow.metaLine": "共 {total} 题 · 已作答 {answered} 题",
|
||||||
|
"flow.unfinishedAt": "第 {index} 题还没有作答,请完成后提交",
|
||||||
|
"flow.pendingN": "还有 {n} 题未作答",
|
||||||
|
"flow.readyToSubmit": "全部题目已作答,可以提交了",
|
||||||
|
"flow.emptyPaper": "这一章还没有题目,暂时无法作答。请管理员在「题库管理」里添加题目并关联到本章。",
|
||||||
|
"flow.emptyPaperShort": "本章暂无题目,无法提交",
|
||||||
|
|
||||||
|
"result.sheet": "成绩单",
|
||||||
|
"result.correctN": "答对 {n} 题",
|
||||||
|
"result.wrongN": "答错 {n} 题",
|
||||||
|
"result.mastery": "知识点掌握度",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MsgKey = keyof typeof zh;
|
export type MsgKey = keyof typeof zh;
|
||||||
|
|
@ -1009,9 +1099,12 @@ const en: Record<MsgKey, string> = {
|
||||||
"daily.nextStep": "Next",
|
"daily.nextStep": "Next",
|
||||||
"daily.retest": "Take this chapter's test again",
|
"daily.retest": "Take this chapter's test again",
|
||||||
"daily.openMap": "Open knowledge map",
|
"daily.openMap": "Open knowledge map",
|
||||||
"chapterTest.selectChapterRequired": "Please select a chapter",
|
"chapterTest.emptyNoTextbook": "No textbooks yet, so there is nothing to build a paper from. Ask an administrator to add a textbook and set up its chapters under Textbooks.",
|
||||||
|
"chapterTest.emptyNoChapters": "{name} has no chapter list yet. Ask an administrator to add chapters under Textbooks and link questions to them in Question Bank, then you can build a paper by chapter.",
|
||||||
|
"chapterTest.emptyPickChapter": "Pick a chapter above and we will build a paper around your weak knowledge points.",
|
||||||
"chapterTest.title": "Choose a chapter to test",
|
"chapterTest.title": "Choose a chapter to test",
|
||||||
"chapterTest.reshuffle": "Reshuffle",
|
"chapterTest.reshuffle": "Reshuffle",
|
||||||
|
"chapterTest.countOption": "{n} questions",
|
||||||
"chapterTest.textbookPlaceholder": "Select textbook",
|
"chapterTest.textbookPlaceholder": "Select textbook",
|
||||||
"chapterTest.chapterPlaceholder": "Select chapter",
|
"chapterTest.chapterPlaceholder": "Select chapter",
|
||||||
"chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank",
|
"chapterTest.noQuestions": "No questions in this chapter yet — generate them in Question Bank",
|
||||||
|
|
@ -1036,8 +1129,10 @@ const en: Record<MsgKey, string> = {
|
||||||
"knowledge.relPrereqOut": "Follow-up points",
|
"knowledge.relPrereqOut": "Follow-up points",
|
||||||
"knowledge.relRelated": "Related knowledge points",
|
"knowledge.relRelated": "Related knowledge points",
|
||||||
"knowledge.masteryTitle": "Mastery by knowledge point",
|
"knowledge.masteryTitle": "Mastery by knowledge point",
|
||||||
|
"knowledge.averageMastery": "Average mastery",
|
||||||
|
"knowledge.practicedCount": "Practiced points",
|
||||||
|
"knowledge.focusCount": "Needs focus",
|
||||||
"knowledge.bandAll": "All",
|
"knowledge.bandAll": "All",
|
||||||
"knowledge.emptyBand": "No knowledge points in this band",
|
|
||||||
"knowledge.hint": "Click any knowledge point for its definition, links and textbook chapters",
|
"knowledge.hint": "Click any knowledge point for its definition, links and textbook chapters",
|
||||||
"knowledge.empty": "No mastery data yet — finish a chapter test and it will be tracked on the knowledge graph",
|
"knowledge.empty": "No mastery data yet — finish a chapter test and it will be tracked on the knowledge graph",
|
||||||
"knowledge.detailTitle": "Knowledge point",
|
"knowledge.detailTitle": "Knowledge point",
|
||||||
|
|
@ -1097,13 +1192,27 @@ const en: Record<MsgKey, string> = {
|
||||||
"llm.autoPlaceholder": "Auto if blank",
|
"llm.autoPlaceholder": "Auto if blank",
|
||||||
"llm.willSave": "Will be saved as: {provider} · {model}",
|
"llm.willSave": "Will be saved as: {provider} · {model}",
|
||||||
"graph.nodeTitle": "{name} (ch {chapters} · q {questions})",
|
"graph.nodeTitle": "{name} (ch {chapters} · q {questions})",
|
||||||
|
"graph.domainAll": "All domains",
|
||||||
|
"graph.searchPlaceholder": "Search knowledge points",
|
||||||
|
"graph.canvasHint": "Click a point to see its links; zoom or drag to adjust the view",
|
||||||
|
"graph.zoomIn": "Zoom in",
|
||||||
|
"graph.zoomOut": "Zoom out",
|
||||||
|
"graph.zoomReset": "Reset view",
|
||||||
|
"graph.emptyDomain": "No knowledge points in this domain yet",
|
||||||
|
"graph.notPracticed": "Not practiced",
|
||||||
|
"graph.masteryValue": "Mastery {n}%",
|
||||||
|
"graph.nodeCounts": "{questions} questions · {chapters} chapters",
|
||||||
|
"graph.columnQuestions": "{n} questions",
|
||||||
|
"graph.edgeLegend": "Solid arrow = prerequisite (study the tail first); dashed = related. Click a point to highlight its direct links",
|
||||||
"graph.domainElementary": "Elementary mathematics",
|
"graph.domainElementary": "Elementary mathematics",
|
||||||
"graph.domainAdvanced": "Advanced mathematics",
|
"graph.domainAdvanced": "Advanced mathematics",
|
||||||
"graph.domainPlaceholder": "Select a domain",
|
|
||||||
"graph.allCategories": "All (full graph)",
|
|
||||||
"graph.add": "New knowledge point",
|
"graph.add": "New knowledge point",
|
||||||
"graph.addRelation": "Add relation",
|
"graph.addRelation": "Add relation",
|
||||||
"graph.detailTitle": "{name} · Details",
|
"graph.detailTitle": "{name} · Details",
|
||||||
|
"graph.detailTitleEmpty": "Knowledge point",
|
||||||
|
"graph.detailEmpty": "Pick a knowledge point in the graph or tree above to see its definition, relations, chapters and courses here.",
|
||||||
|
"graph.viewGraph": "Graph",
|
||||||
|
"graph.viewTree": "Tree",
|
||||||
"graph.fieldDomain": "Domain",
|
"graph.fieldDomain": "Domain",
|
||||||
"graph.fieldCategory": "Category",
|
"graph.fieldCategory": "Category",
|
||||||
"graph.fieldUsage": "Used by",
|
"graph.fieldUsage": "Used by",
|
||||||
|
|
@ -1144,10 +1253,15 @@ const en: Record<MsgKey, string> = {
|
||||||
"graph.msgUnlinked": "Link removed",
|
"graph.msgUnlinked": "Link removed",
|
||||||
"graph.msgUnlinkFailed": "Unlink failed",
|
"graph.msgUnlinkFailed": "Unlink failed",
|
||||||
"bank.colQuestion": "Question",
|
"bank.colQuestion": "Question",
|
||||||
"bank.colSource": "Source / Chapter",
|
"bank.colChapter": "Chapter",
|
||||||
"bank.generated": "AI-generated",
|
"bank.chapterTree": "Textbooks / chapters",
|
||||||
"bank.builtin": "Built-in",
|
"bank.expandAll": "Expand all",
|
||||||
|
"bank.collapseAll": "Collapse all",
|
||||||
|
"bank.expandBook": "Expand chapters",
|
||||||
|
"bank.collapseBook": "Collapse chapters",
|
||||||
|
"bank.noChaptersYet": "No chapters yet",
|
||||||
"bank.noChapter": "No chapter",
|
"bank.noChapter": "No chapter",
|
||||||
|
"bank.difficultyAll": "Any level",
|
||||||
"bank.colDifficulty": "Difficulty",
|
"bank.colDifficulty": "Difficulty",
|
||||||
"bank.colUsage": "Usage",
|
"bank.colUsage": "Usage",
|
||||||
"bank.usedN": "Used {n} times",
|
"bank.usedN": "Used {n} times",
|
||||||
|
|
@ -1424,6 +1538,72 @@ const en: Record<MsgKey, string> = {
|
||||||
"api.taskKindUnsupported": "Unsupported task type: {kind}",
|
"api.taskKindUnsupported": "Unsupported task type: {kind}",
|
||||||
"api.knowledgeSomeMissing": "Some knowledge points do not exist",
|
"api.knowledgeSomeMissing": "Some knowledge points do not exist",
|
||||||
"api.ebookFileInvalid": "{name} is not a valid {format} file",
|
"api.ebookFileInvalid": "{name} is not a valid {format} file",
|
||||||
|
// —— Split paper workbench (shared by bank / records / errors / test) ——
|
||||||
|
"paper.prevPage": "Previous page",
|
||||||
|
"paper.nextPage": "Next page",
|
||||||
|
"paper.pageRange": "{start}–{end} of {total} questions",
|
||||||
|
"paper.unanswered": "Not answered",
|
||||||
|
"paper.yourAnswer": "Your answer: {answer}",
|
||||||
|
"paper.yourAnswerCorrect": "Your answer: {answer} (correct)",
|
||||||
|
"paper.correctAnswer": "Correct answer: {answer}",
|
||||||
|
"paper.score": "Score",
|
||||||
|
"paper.filterAll": "All {n}",
|
||||||
|
"paper.filterWrong": "Wrong {n}",
|
||||||
|
"paper.expandExplanations": "Expand all explanations",
|
||||||
|
"paper.showExplanation": "Show explanation",
|
||||||
|
"paper.emptyPaper": "This paper has no questions yet",
|
||||||
|
|
||||||
|
"records.asideTitle": "Practice log",
|
||||||
|
"records.search": "Search session or date",
|
||||||
|
"records.stat": "{sessions} sessions · {accuracy}% overall",
|
||||||
|
"records.summary": "{correct} / {total} correct",
|
||||||
|
"records.metaLine": "{date} · {total} questions · {correct} correct · {accuracy}% accuracy",
|
||||||
|
"records.pickOne": "Pick a session on the left to review it question by question",
|
||||||
|
"records.startTest": "Take a chapter test",
|
||||||
|
"records.needsReview": "Worth a review",
|
||||||
|
|
||||||
|
"errors.asideTitle": "Mistake list",
|
||||||
|
"errors.search": "Search stem or knowledge point",
|
||||||
|
"errors.filterKnowledge": "Filter by knowledge",
|
||||||
|
"errors.stat": "{n} questions",
|
||||||
|
"errors.detailTitle": "Mistake walkthrough",
|
||||||
|
"errors.metaLine": "Source: {source} · logged {date} · diagnosis: {diagnosis}",
|
||||||
|
"errors.position": "Question {index} of {total}",
|
||||||
|
"errors.prev": "Previous",
|
||||||
|
"errors.next": "Next",
|
||||||
|
|
||||||
|
"bank.asideTitle": "Textbooks & chapters",
|
||||||
|
"bank.navAll": "All questions",
|
||||||
|
"bank.unattachedHint": "{n} of them are not linked to a chapter yet",
|
||||||
|
"bank.search": "Search stem, options or knowledge point",
|
||||||
|
"bank.metaLine": "{total} questions · {used} used",
|
||||||
|
"bank.noMatch": "No question matches the current filters. Try another chapter or clear them",
|
||||||
|
"bank.viewCard": "Paper view",
|
||||||
|
"bank.viewTable": "Table view",
|
||||||
|
"bank.usageAll": "Any usage",
|
||||||
|
"bank.filterUsed": "Used",
|
||||||
|
"bank.filterUnused": "Unused",
|
||||||
|
"bank.difficultyValue": "{stars}",
|
||||||
|
|
||||||
|
"flow.sheet": "Answer sheet",
|
||||||
|
"flow.answered": "{answered} / {total} answered",
|
||||||
|
"flow.currentNo": "Question {index}",
|
||||||
|
"flow.currentPicked": "Picked {letter}",
|
||||||
|
"flow.jumpToUnanswered": "Jump to question {index} (unanswered)",
|
||||||
|
"flow.answeredAt": "Question {index}: {letter}",
|
||||||
|
"flow.unanswered": "Unanswered",
|
||||||
|
"flow.answeredTag": "Answered",
|
||||||
|
"flow.metaLine": "{total} questions · {answered} answered",
|
||||||
|
"flow.unfinishedAt": "Question {index} is still unanswered. Finish it before submitting",
|
||||||
|
"flow.pendingN": "{n} question(s) left",
|
||||||
|
"flow.readyToSubmit": "All questions answered — ready to submit",
|
||||||
|
"flow.emptyPaper": "This chapter has no questions yet, so there is nothing to answer. Ask an administrator to add questions in Question Bank and link them to this chapter.",
|
||||||
|
"flow.emptyPaperShort": "No questions in this chapter yet",
|
||||||
|
|
||||||
|
"result.sheet": "Report card",
|
||||||
|
"result.correctN": "{n} correct",
|
||||||
|
"result.wrongN": "{n} wrong",
|
||||||
|
"result.mastery": "Knowledge mastery",
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentLang: Lang = "zh";
|
let currentLang: Lang = "zh";
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, Select, Space, Tag, Typography } from "antd";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import { Button, Select, Tag, Typography } from "antd";
|
||||||
BookOutlined,
|
import { BookOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
InfoCircleOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import QuestionFlow from "../components/QuestionFlow";
|
import QuestionFlow from "../components/QuestionFlow";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
import { Hint, KnowledgeTags, Page, Panel, Toolbar } from "../components/ui";
|
import { PaperPlaceholder } from "../components/PaperReview";
|
||||||
|
import { KnowledgeTags, Page } from "../components/ui";
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
import { tagColor } from "../theme";
|
import { tagColor } from "../theme";
|
||||||
import type { ViewId } from "../nav";
|
import type { ViewId } from "../nav";
|
||||||
|
|
@ -21,6 +19,10 @@ interface ChapterTestProps {
|
||||||
onReadOnline?: (textbookId: number, page?: number) => void;
|
onReadOnline?: (textbookId: number, page?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 默认题量与可选题量(后端 limit 上限为 10)。 */
|
||||||
|
const DEFAULT_TEST_COUNT = 6;
|
||||||
|
const TEST_COUNT_OPTIONS = [4, 6, 8, 10];
|
||||||
|
|
||||||
export default function ChapterTest({
|
export default function ChapterTest({
|
||||||
initialChapterId,
|
initialChapterId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
|
@ -35,6 +37,8 @@ export default function ChapterTest({
|
||||||
initialChapterId ?? undefined,
|
initialChapterId ?? undefined,
|
||||||
);
|
);
|
||||||
const [variant, setVariant] = useState(0);
|
const [variant, setVariant] = useState(0);
|
||||||
|
/** 组卷题量:默认 6 题,用户可改(后端上限 10) */
|
||||||
|
const [count, setCount] = useState(DEFAULT_TEST_COUNT);
|
||||||
|
|
||||||
// 初始只有章节 id(从教材目录 / 今日任务跳转)时,先找到所属教材
|
// 初始只有章节 id(从教材目录 / 今日任务跳转)时,先找到所属教材
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -63,12 +67,12 @@ export default function ChapterTest({
|
||||||
if (first) setChapterId(first.id);
|
if (first) setChapterId(first.id);
|
||||||
}, [currentBook, chapterId]);
|
}, [currentBook, chapterId]);
|
||||||
|
|
||||||
|
// 组卷要先有章节:缺前置条件时干脆不发请求,交给下面的引导空态,
|
||||||
|
// 而不是 Promise.reject 把「还没选章节」显示成红色的「加载失败」。
|
||||||
const sessionState = useApi(
|
const sessionState = useApi(
|
||||||
() =>
|
() => api.chapterTest(chapterId as number, variant, count),
|
||||||
chapterId
|
[chapterId, variant, count],
|
||||||
? api.chapterTest(chapterId, variant, 5)
|
{ enabled: Boolean(chapterId) },
|
||||||
: Promise.reject(new Error(t("chapterTest.selectChapterRequired"))),
|
|
||||||
[chapterId, variant],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function chooseBook(id: number) {
|
function chooseBook(id: number) {
|
||||||
|
|
@ -81,12 +85,70 @@ export default function ChapterTest({
|
||||||
setVariant((value) => value + 1);
|
setVariant((value) => value + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const note = [
|
||||||
|
currentChapter?.summary,
|
||||||
|
t("chapterTest.logic"),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组卷缺前置条件时的引导:没有教材 / 这本教材没章节 / 还没选章节。
|
||||||
|
* 管理员菜单里没有本页,所以这里只对学生说话——告诉他缺什么、谁去补。
|
||||||
|
*/
|
||||||
|
const emptyGuide: { description: ReactNode } | null =
|
||||||
|
chapterId || textbooksState.loading || textbooksState.error
|
||||||
|
? null
|
||||||
|
: textbooks.length === 0
|
||||||
|
? { description: t("chapterTest.emptyNoTextbook") }
|
||||||
|
: chapters.length === 0
|
||||||
|
? {
|
||||||
|
description: t("chapterTest.emptyNoChapters", {
|
||||||
|
name: currentBook?.name ?? "",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: { description: t("chapterTest.emptyPickChapter") };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Panel
|
{/* 组卷条件收在上方一条,下面整块留给答题工作台 */}
|
||||||
title={t("chapterTest.title")}
|
<div className="test-launcher">
|
||||||
extra={
|
<div className="test-launcher-row">
|
||||||
<Space size={8} wrap>
|
<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 && (
|
{onReadOnline && currentBook?.ebook_format && (
|
||||||
<Button
|
<Button
|
||||||
icon={<BookOutlined />}
|
icon={<BookOutlined />}
|
||||||
|
|
@ -102,73 +164,35 @@ export default function ChapterTest({
|
||||||
: t("resources.readOnline")}
|
: t("resources.readOnline")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
</div>
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{currentChapter && (
|
{currentChapter && (
|
||||||
<div style={{ marginTop: 12 }}>
|
<div className="test-launcher-meta">
|
||||||
<Typography.Text type="secondary">
|
<span className="test-launcher-tags">
|
||||||
{currentChapter.summary || "—"}
|
|
||||||
</Typography.Text>
|
|
||||||
<Space size={[4, 4]} wrap style={{ marginTop: 6 }}>
|
|
||||||
<KnowledgeTags names={currentChapter.knowledge_names} />
|
<KnowledgeTags names={currentChapter.knowledge_names} />
|
||||||
{currentChapter.question_count === 0 && (
|
{currentChapter.question_count === 0 && (
|
||||||
<Tag color={tagColor.warning}>
|
<Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
|
||||||
{t("chapterTest.noQuestions")}
|
{t("chapterTest.noQuestions")}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
{currentChapter.question_count > 0 &&
|
{currentChapter.question_count > 0 &&
|
||||||
currentChapter.question_count < 5 && (
|
currentChapter.question_count < 5 && (
|
||||||
<Tag color={tagColor.warning}>
|
<Tag color={tagColor.warning} style={{ marginInlineEnd: 0 }}>
|
||||||
{t("chapterTest.fewQuestions", { n: currentChapter.question_count })}
|
{t("chapterTest.fewQuestions", {
|
||||||
|
n: currentChapter.question_count,
|
||||||
|
})}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</span>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
className="test-launcher-note"
|
||||||
|
ellipsis={{ tooltip: note }}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Hint>
|
|
||||||
<InfoCircleOutlined style={{ marginRight: 6 }} />
|
|
||||||
{t("chapterTest.logic")}
|
|
||||||
</Hint>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{sessionState.data ? (
|
{sessionState.data ? (
|
||||||
<QuestionFlow
|
<QuestionFlow
|
||||||
|
|
@ -177,11 +201,19 @@ export default function ChapterTest({
|
||||||
onRestart={restart}
|
onRestart={restart}
|
||||||
onExit={() => onNavigate("dashboard")}
|
onExit={() => onNavigate("dashboard")}
|
||||||
/>
|
/>
|
||||||
|
) : textbooksState.error ? (
|
||||||
|
<AsyncState
|
||||||
|
loading={false}
|
||||||
|
error={textbooksState.error}
|
||||||
|
onRetry={textbooksState.reload}
|
||||||
|
/>
|
||||||
|
) : emptyGuide ? (
|
||||||
|
<PaperPlaceholder description={emptyGuide.description} />
|
||||||
) : (
|
) : (
|
||||||
<AsyncState
|
<AsyncState
|
||||||
loading={sessionState.loading}
|
loading={sessionState.loading || textbooksState.loading}
|
||||||
error={sessionState.error}
|
error={sessionState.error}
|
||||||
onRetry={() => setVariant((value) => value + 1)}
|
onRetry={restart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,8 @@ export default function Daily({ onStartTest, onReadOnline, onNavigate }: DailyPr
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.completeDaily(task.chapter_id, value);
|
const result = await api.completeDaily(task.chapter_id, value);
|
||||||
setSubmitted(result);
|
// 自评以刚提交的值为准:万一接口回读滞后,页面也不会显示成“已确认”或空自评
|
||||||
|
setSubmitted({ ...result, rating: result.rating || value });
|
||||||
setRating(value);
|
setRating(value);
|
||||||
setDoneInfo({
|
setDoneInfo({
|
||||||
from: isEdit ? result.streak : (task.streak ?? 0),
|
from: isEdit ? result.streak : (task.streak ?? 0),
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,271 @@
|
||||||
import { Tag } from "antd";
|
/**
|
||||||
import type { ColumnsType } from "antd/es/table";
|
* 错题本:左侧错题清单,右侧单题精讲。
|
||||||
|
*
|
||||||
|
* 错题的用法是「一道一道啃」,不是一屏塞满表格,
|
||||||
|
* 因此左栏负责挑题(可按关键词与知识点筛),右栏按试卷版式给出
|
||||||
|
* 题干、你的作答、正确答案与解析,底部可以直接翻到下一道。
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button, Flex, Input, Select, Tag, Typography } from "antd";
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import { QuestionDetail } from "../components/QuestionDetail";
|
import { AnswerCompare, PaperQuestion } from "../components/Paper";
|
||||||
|
import { OptionList, optionLetter } from "../components/Options";
|
||||||
|
import { Pane, SplitView } from "../components/SplitView";
|
||||||
|
import { PaperMetaText } from "../components/PaperReview";
|
||||||
import RichText from "../components/RichText";
|
import RichText from "../components/RichText";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
import {
|
import { EmptyState, KnowledgeTags } from "../components/ui";
|
||||||
DataTable,
|
|
||||||
EmptyState,
|
|
||||||
KnowledgeTags,
|
|
||||||
Panel,
|
|
||||||
SubText,
|
|
||||||
} from "../components/ui";
|
|
||||||
import { backendText, enumLabel } from "../i18n";
|
import { backendText, enumLabel } from "../i18n";
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
import { tagColor } from "../theme";
|
import { tagColor } from "../theme";
|
||||||
import type { ErrorEntry } from "../types";
|
import type { ErrorEntry } from "../types";
|
||||||
|
|
||||||
|
function knowledgeOf(entry: ErrorEntry): string[] {
|
||||||
|
const names = (entry.knowledge_names ?? []).length
|
||||||
|
? (entry.knowledge_names as string[])
|
||||||
|
: [entry.knowledge_name];
|
||||||
|
return names.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Errors() {
|
export default function Errors() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
const { data, loading, error, reload } = useApi(api.errors);
|
const { data, loading, error, reload } = useApi(api.errors);
|
||||||
|
const entries = useMemo(() => data ?? [], [data]);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [knowledge, setKnowledge] = useState<string | undefined>(undefined);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
const columns: ColumnsType<ErrorEntry> = [
|
const knowledgeOptions = useMemo(() => {
|
||||||
{
|
const names = new Set<string>();
|
||||||
title: t("errors.colRound"),
|
entries.forEach((entry) => knowledgeOf(entry).forEach((name) => names.add(name)));
|
||||||
dataIndex: "source_label",
|
return Array.from(names)
|
||||||
width: 170,
|
.sort()
|
||||||
render: (value: string) => <b>{value}</b>,
|
.map((name) => ({ value: name, label: name }));
|
||||||
},
|
}, [entries]);
|
||||||
{
|
|
||||||
title: t("errors.colQuestion"),
|
const visible = useMemo(() => {
|
||||||
key: "stem",
|
const text = keyword.trim().toLowerCase();
|
||||||
render: (_, entry) =>
|
return entries.filter((entry) => {
|
||||||
entry.stem ? (
|
if (knowledge && !knowledgeOf(entry).includes(knowledge)) return false;
|
||||||
<RichText content={entry.stem} compact />
|
if (!text) return true;
|
||||||
) : (
|
return (
|
||||||
<SubText as="span">{t("errors.legacyNoDetail")}</SubText>
|
(entry.stem ?? "").toLowerCase().includes(text) ||
|
||||||
),
|
entry.source_label.toLowerCase().includes(text)
|
||||||
},
|
);
|
||||||
{
|
});
|
||||||
title: t("errors.colKnowledge"),
|
}, [entries, keyword, knowledge]);
|
||||||
key: "knowledge",
|
|
||||||
render: (_, entry) => (
|
useEffect(() => {
|
||||||
<KnowledgeTags
|
if (visible.length === 0) {
|
||||||
names={
|
if (selectedId !== null) setSelectedId(null);
|
||||||
(entry.knowledge_names ?? []).length > 0
|
return;
|
||||||
? entry.knowledge_names
|
}
|
||||||
: [entry.knowledge_name]
|
if (selectedId === null || !visible.some((entry) => entry.id === selectedId)) {
|
||||||
|
setSelectedId(visible[0].id);
|
||||||
|
}
|
||||||
|
}, [visible, selectedId]);
|
||||||
|
|
||||||
|
const index = visible.findIndex((entry) => entry.id === selectedId);
|
||||||
|
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 (
|
return (
|
||||||
<Panel>
|
<SplitView
|
||||||
<AsyncState loading={loading} error={error} onRetry={reload} />
|
storageKey="nex_math.split.errors.aside"
|
||||||
{data && (
|
defaultWidth={330}
|
||||||
<DataTable<ErrorEntry>
|
aside={
|
||||||
rowKey="id"
|
<Pane
|
||||||
columns={columns}
|
title={t("errors.asideTitle")}
|
||||||
dataSource={data}
|
extra={
|
||||||
locale={{
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
emptyText: (
|
{t("errors.stat", { n: entries.length })}
|
||||||
<EmptyState description={t("errors.empty")} />
|
</Typography.Text>
|
||||||
),
|
}
|
||||||
}}
|
toolbar={
|
||||||
expandable={{
|
<>
|
||||||
expandedRowRender: (entry) => (
|
<Input
|
||||||
<QuestionDetail
|
allowClear
|
||||||
stem={entry.stem}
|
size="small"
|
||||||
options={entry.options}
|
prefix={<SearchOutlined />}
|
||||||
selected={entry.selected}
|
placeholder={t("errors.search")}
|
||||||
correctIndex={entry.correct_index}
|
value={keyword}
|
||||||
explanation={entry.explanation}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
legacyHint={t("errors.legacyHint")}
|
style={{ flex: "1 1 140px", minWidth: 130 }}
|
||||||
/>
|
/>
|
||||||
),
|
<Select
|
||||||
}}
|
allowClear
|
||||||
|
size="small"
|
||||||
|
showSearch
|
||||||
|
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,
|
Flex,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
|
|
@ -12,8 +13,10 @@ import {
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { TreeDataNode } from "antd";
|
import type { TreeDataNode } from "antd";
|
||||||
import {
|
import {
|
||||||
|
ApartmentOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
PartitionOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
|
@ -21,6 +24,7 @@ import { api } from "../api/client";
|
||||||
import { HeaderActions } from "../components/Layout";
|
import { HeaderActions } from "../components/Layout";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
|
import KnowledgeGraphView from "../components/KnowledgeGraphView";
|
||||||
import {
|
import {
|
||||||
EditorDrawer,
|
EditorDrawer,
|
||||||
FormRow,
|
FormRow,
|
||||||
|
|
@ -41,6 +45,10 @@ import type {
|
||||||
KnowledgeGraphNode,
|
KnowledgeGraphNode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
/** 领域筛选取「全部」时的取值,取值本身不参与提交。 */
|
||||||
|
const ALL_DOMAINS = "all";
|
||||||
|
|
||||||
|
/** 树视图:按「包含」关系折叠成层级,和画布看的是同一份数据。 */
|
||||||
function buildTreeData(
|
function buildTreeData(
|
||||||
nodes: KnowledgeGraphNode[],
|
nodes: KnowledgeGraphNode[],
|
||||||
edges: KnowledgeGraphEdge[],
|
edges: KnowledgeGraphEdge[],
|
||||||
|
|
@ -76,6 +84,9 @@ function buildTreeData(
|
||||||
return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item));
|
return rootIds.map(convert).filter((item): item is TreeDataNode => Boolean(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图谱 / 树两种呈现方式,切换器放在筛选行的右端。 */
|
||||||
|
type GraphViewMode = "graph" | "tree";
|
||||||
|
|
||||||
export default function KnowledgeGraph() {
|
export default function KnowledgeGraph() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
|
|
@ -83,7 +94,7 @@ export default function KnowledgeGraph() {
|
||||||
const { data, loading, error, reload } = useApi(api.knowledgeGraph);
|
const { data, loading, error, reload } = useApi(api.knowledgeGraph);
|
||||||
const coursesState = useApi(api.courses);
|
const coursesState = useApi(api.courses);
|
||||||
const [domain, setDomain] = useState("初等");
|
const [domain, setDomain] = useState("初等");
|
||||||
const [categoryId, setCategoryId] = useState<number>(0);
|
const [viewMode, setViewMode] = useState<GraphViewMode>("graph");
|
||||||
const [selected, setSelected] = useState<KnowledgeGraphNode | null>(null);
|
const [selected, setSelected] = useState<KnowledgeGraphNode | null>(null);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<KnowledgeGraphNode | null>(null);
|
const [editing, setEditing] = useState<KnowledgeGraphNode | null>(null);
|
||||||
|
|
@ -102,79 +113,44 @@ export default function KnowledgeGraph() {
|
||||||
}, [data, selected]);
|
}, [data, selected]);
|
||||||
|
|
||||||
const nodes = useMemo(
|
const nodes = useMemo(
|
||||||
() => (data?.nodes ?? []).filter((node) => node.domain === domain),
|
() =>
|
||||||
|
(data?.nodes ?? []).filter(
|
||||||
|
(node) => domain === ALL_DOMAINS || node.domain === domain,
|
||||||
|
),
|
||||||
[data, domain],
|
[data, domain],
|
||||||
);
|
);
|
||||||
const domainRoot = nodes.find(
|
const visibleIds = useMemo(
|
||||||
(node) => node.name === (domain === "初等" ? "初等数学" : "高等数学"),
|
() => new Set(nodes.map((node) => node.id)),
|
||||||
|
[nodes],
|
||||||
);
|
);
|
||||||
const categoryRoots = useMemo(
|
const visibleEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(data?.edges ?? [])
|
(data?.edges ?? []).filter(
|
||||||
.filter(
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||||
(edge) =>
|
),
|
||||||
edge.relation_type === "包含" &&
|
[data, visibleIds],
|
||||||
edge.source === domainRoot?.id,
|
|
||||||
)
|
|
||||||
.map((edge) => nodes.find((node) => node.id === edge.target))
|
|
||||||
.filter((node): node is KnowledgeGraphNode => Boolean(node)),
|
|
||||||
[data, nodes, domainRoot],
|
|
||||||
);
|
);
|
||||||
|
/** 树视图的根:在本域内没有被「包含」指向的节点(通常就是域根)。 */
|
||||||
const subNodes = useMemo(
|
const treeData = useMemo(() => {
|
||||||
() => {
|
const roots = nodes
|
||||||
if (categoryId === 0) return nodes;
|
|
||||||
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
|
|
||||||
.filter(
|
.filter(
|
||||||
(node) =>
|
(node) =>
|
||||||
!(data?.edges ?? []).some(
|
!visibleEdges.some(
|
||||||
(edge) =>
|
(edge) =>
|
||||||
edge.relation_type === "包含" &&
|
edge.relation_type === "包含" && edge.target === node.id,
|
||||||
edge.target === node.id &&
|
|
||||||
subNodeIds.has(edge.source),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map((node) => node.id);
|
.map((node) => node.id);
|
||||||
|
return buildTreeData(nodes, visibleEdges, roots);
|
||||||
const treeData = useMemo(
|
}, [nodes, visibleEdges]);
|
||||||
() => buildTreeData(subNodes, data?.edges ?? [], roots),
|
|
||||||
[subNodes, data, roots],
|
|
||||||
);
|
|
||||||
const expandedRootKeys = roots.map(String);
|
|
||||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
const defaultCategory =
|
|
||||||
categoryRoots.find((node) => node.id === categoryId)?.name ?? "";
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
domain,
|
domain: domain === ALL_DOMAINS ? "初等" : domain,
|
||||||
category: defaultCategory,
|
category: selected?.category ?? "",
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
|
|
@ -329,62 +305,84 @@ export default function KnowledgeGraph() {
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
filters={
|
filters={
|
||||||
<>
|
|
||||||
<Select
|
<Select
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(value) => {
|
onChange={(value) => setDomain(value)}
|
||||||
setDomain(value);
|
style={{ width: 150 }}
|
||||||
setCategoryId(0);
|
|
||||||
}}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
options={[
|
||||||
|
{ value: ALL_DOMAINS, label: t("graph.domainAll") },
|
||||||
{ value: "初等", label: t("graph.domainElementary") },
|
{ value: "初等", label: t("graph.domainElementary") },
|
||||||
{ value: "高等", label: t("graph.domainAdvanced") },
|
{ value: "高等", label: t("graph.domainAdvanced") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
}
|
||||||
style={{ width: 240 }}
|
extra={
|
||||||
placeholder={t("graph.domainPlaceholder")}
|
<Segmented
|
||||||
value={categoryId}
|
size="small"
|
||||||
onChange={(value) => {
|
value={viewMode}
|
||||||
setCategoryId(value);
|
onChange={(value) => setViewMode(value as GraphViewMode)}
|
||||||
setSelected(null);
|
|
||||||
}}
|
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: t("graph.allCategories") },
|
{
|
||||||
...categoryRoots.map((node) => ({
|
value: "graph",
|
||||||
value: node.id,
|
label: t("graph.viewGraph"),
|
||||||
label: node.name,
|
icon: <ApartmentOutlined />,
|
||||||
})),
|
},
|
||||||
|
{
|
||||||
|
value: "tree",
|
||||||
|
label: t("graph.viewTree"),
|
||||||
|
icon: <PartitionOutlined />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AsyncState loading={loading} error={error} onRetry={reload} />
|
<AsyncState loading={loading} error={error} onRetry={reload} />
|
||||||
{data && (
|
{data &&
|
||||||
<div className="tree-box">
|
(viewMode === "graph" ? (
|
||||||
|
<KnowledgeGraphView
|
||||||
|
nodes={nodes}
|
||||||
|
edges={visibleEdges}
|
||||||
|
selectedId={selected?.id ?? null}
|
||||||
|
onSelect={(node) => {
|
||||||
|
const full = data.nodes.find((item) => item.id === node.id);
|
||||||
|
setSelected(full ?? null);
|
||||||
|
}}
|
||||||
|
domainLabel={(value) =>
|
||||||
|
value === "高等"
|
||||||
|
? t("graph.domainAdvanced")
|
||||||
|
: t("graph.domainElementary")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="graph-tree">
|
||||||
<Tree
|
<Tree
|
||||||
showLine
|
showLine
|
||||||
selectable
|
selectable
|
||||||
defaultExpandedKeys={expandedRootKeys}
|
defaultExpandAll
|
||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
|
selectedKeys={selected ? [String(selected.id)] : []}
|
||||||
onSelect={(keys) => {
|
onSelect={(keys) => {
|
||||||
const id = Number(keys[0]);
|
const node =
|
||||||
const node = data?.nodes.find((item) => item.id === id);
|
data.nodes.find((item) => item.id === Number(keys[0])) ??
|
||||||
if (node) setSelected(node);
|
null;
|
||||||
|
setSelected(node);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{selected && (
|
{/* 右侧信息面板常驻:不管有没有选中,图谱的宽度都不会跳一下 */}
|
||||||
<Panel
|
<Panel
|
||||||
muted
|
muted
|
||||||
className="graph-detail"
|
className="graph-detail"
|
||||||
title={t("graph.detailTitle", { name: selected.name })}
|
title={
|
||||||
|
selected
|
||||||
|
? t("graph.detailTitle", { name: selected.name })
|
||||||
|
: t("graph.detailTitleEmpty")
|
||||||
|
}
|
||||||
extra={
|
extra={
|
||||||
|
selected ? (
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
|
|
@ -402,8 +400,13 @@ export default function KnowledgeGraph() {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!selected ? (
|
||||||
|
<Hint>{t("graph.detailEmpty")}</Hint>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
size="small"
|
size="small"
|
||||||
column={2}
|
column={2}
|
||||||
|
|
@ -417,6 +420,7 @@ export default function KnowledgeGraph() {
|
||||||
chapters: selected.chapter_count,
|
chapters: selected.chapter_count,
|
||||||
questions: selected.question_count,
|
questions: selected.question_count,
|
||||||
}),
|
}),
|
||||||
|
span: 2,
|
||||||
},
|
},
|
||||||
{ key: "desc", label: t("graph.fieldDescription"), children: selected.description || "—", span: 2 },
|
{ key: "desc", label: t("graph.fieldDescription"), children: selected.description || "—", span: 2 },
|
||||||
]}
|
]}
|
||||||
|
|
@ -560,8 +564,9 @@ export default function KnowledgeGraph() {
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Panel>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<EditorDrawer
|
<EditorDrawer
|
||||||
title={
|
title={
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Drawer,
|
Drawer,
|
||||||
Flex,
|
Flex,
|
||||||
|
Input,
|
||||||
Progress,
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Segmented,
|
Segmented,
|
||||||
|
|
@ -17,10 +18,12 @@ import {
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
|
import KnowledgeGraphView from "../components/KnowledgeGraphView";
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Hint,
|
Hint,
|
||||||
|
|
@ -31,11 +34,19 @@ import {
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { enumLabel, relationDirectionLabel } from "../i18n";
|
import { enumLabel, relationDirectionLabel } from "../i18n";
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
import { MASTERY_BANDS, masteryProgressColor, masteryTagColor, tagColor } from "../theme";
|
import {
|
||||||
|
MASTERY_BANDS,
|
||||||
|
masteryProgressColor,
|
||||||
|
masteryTagColor,
|
||||||
|
status,
|
||||||
|
tagColor,
|
||||||
|
} from "../theme";
|
||||||
import type { KnowledgeRelatedNode } from "../types";
|
import type { KnowledgeRelatedNode } from "../types";
|
||||||
|
|
||||||
/** 分档筛选里「不过滤」的取值。 */
|
/** 分档筛选里「不过滤」的取值。 */
|
||||||
const ALL_BANDS = "all";
|
const ALL_BANDS = "all";
|
||||||
|
/** 领域筛选里「不过滤」的取值。 */
|
||||||
|
const ALL_DOMAINS = "all";
|
||||||
|
|
||||||
interface KnowledgeMapProps {
|
interface KnowledgeMapProps {
|
||||||
onStartChapterTest?: (chapterId: number) => void;
|
onStartChapterTest?: (chapterId: number) => void;
|
||||||
|
|
@ -56,8 +67,10 @@ export default function KnowledgeMap({
|
||||||
}: KnowledgeMapProps) {
|
}: KnowledgeMapProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
const { data, loading, error, reload } = useApi(api.knowledge);
|
const { data, loading, error, reload } = useApi(api.knowledgeMap);
|
||||||
const [band, setBand] = useState<string>(initialBand ?? ALL_BANDS);
|
const [band, setBand] = useState<string>(initialBand ?? ALL_BANDS);
|
||||||
|
const [domain, setDomain] = useState<string>(ALL_DOMAINS);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const detailState = useApi(
|
const detailState = useApi(
|
||||||
() => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)),
|
() => (selectedId ? api.knowledgeDetail(selectedId) : Promise.resolve(null)),
|
||||||
|
|
@ -65,9 +78,50 @@ export default function KnowledgeMap({
|
||||||
);
|
);
|
||||||
const detail = detailState.data;
|
const detail = detailState.data;
|
||||||
|
|
||||||
const knowledge = data ?? [];
|
const nodes = data?.nodes ?? [];
|
||||||
const points =
|
const edges = data?.edges ?? [];
|
||||||
band === ALL_BANDS ? knowledge : knowledge.filter((item) => item.status === band);
|
const visibleNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes.filter((node) => domain === ALL_DOMAINS || node.domain === domain),
|
||||||
|
[nodes, domain],
|
||||||
|
);
|
||||||
|
const visibleIds = useMemo(
|
||||||
|
() => new Set(visibleNodes.map((node) => node.id)),
|
||||||
|
[visibleNodes],
|
||||||
|
);
|
||||||
|
const visibleEdges = useMemo(
|
||||||
|
() =>
|
||||||
|
edges.filter(
|
||||||
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||||
|
),
|
||||||
|
[edges, visibleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 档位与关键词只把不相关的点淡出,不改布局:
|
||||||
|
* 地图的骨架(先修链、分类列)要一直在,才看得出“我卡在哪一段”。
|
||||||
|
*/
|
||||||
|
const dimmed = useMemo(() => {
|
||||||
|
const text = keyword.trim().toLowerCase();
|
||||||
|
const set = new Set<number>();
|
||||||
|
visibleNodes.forEach((node) => {
|
||||||
|
const bandOk = band === ALL_BANDS || node.status === band;
|
||||||
|
const textOk =
|
||||||
|
!text ||
|
||||||
|
node.name.toLowerCase().includes(text) ||
|
||||||
|
node.category.toLowerCase().includes(text);
|
||||||
|
if (!bandOk || !textOk) set.add(node.id);
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}, [visibleNodes, band, keyword]);
|
||||||
|
|
||||||
|
const practiced = nodes.filter((node) => node.practiced);
|
||||||
|
const average = practiced.length
|
||||||
|
? Math.round(
|
||||||
|
practiced.reduce((sum, node) => sum + node.mastery, 0) / practiced.length,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const focusCount = practiced.filter((node) => node.status === "重点").length;
|
||||||
|
|
||||||
const relatedGroups = (detail?.related ?? []).reduce<
|
const relatedGroups = (detail?.related ?? []).reduce<
|
||||||
Record<string, KnowledgeRelatedNode[]>
|
Record<string, KnowledgeRelatedNode[]>
|
||||||
|
|
@ -81,7 +135,7 @@ export default function KnowledgeMap({
|
||||||
<Page>
|
<Page>
|
||||||
<AsyncState loading={loading} error={error} onRetry={reload} />
|
<AsyncState loading={loading} error={error} onRetry={reload} />
|
||||||
{data &&
|
{data &&
|
||||||
(data.length > 0 ? (
|
(nodes.length > 0 ? (
|
||||||
<Panel title={t("knowledge.masteryTitle")}>
|
<Panel title={t("knowledge.masteryTitle")}>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
filters={
|
filters={
|
||||||
|
|
@ -97,36 +151,60 @@ export default function KnowledgeMap({
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Hint>{t("knowledge.hint")}</Hint>
|
<Segmented
|
||||||
|
value={domain}
|
||||||
|
onChange={(value) => setDomain(String(value))}
|
||||||
|
options={[
|
||||||
|
{ value: ALL_DOMAINS, label: t("graph.domainAll") },
|
||||||
|
{ value: "初等", label: t("graph.domainElementary") },
|
||||||
|
{ value: "高等", label: t("graph.domainAdvanced") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
size="small"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder={t("graph.searchPlaceholder")}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{points.length === 0 ? (
|
<Flex className="kg-summary" gap={32} wrap align="center">
|
||||||
<EmptyState description={t("knowledge.emptyBand")} />
|
<Statistic
|
||||||
) : (
|
title={t("knowledge.averageMastery")}
|
||||||
<Row gutter={[16, 16]}>
|
value={average}
|
||||||
{points.map((item) => (
|
suffix="%"
|
||||||
<Col xs={24} md={12} key={item.id}>
|
|
||||||
<Card
|
|
||||||
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)}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
<Statistic
|
||||||
</Col>
|
title={t("knowledge.practicedCount")}
|
||||||
))}
|
value={practiced.length}
|
||||||
</Row>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Progress,
|
Progress,
|
||||||
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
|
|
@ -16,24 +16,28 @@ import {
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
DownOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { HeaderActions } from "../components/Layout";
|
import { HeaderActions } from "../components/Layout";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
import { QuestionDetail } from "../components/QuestionDetail";
|
import { Pane, SplitView } from "../components/SplitView";
|
||||||
|
import { PaperMetaText, PaperReview } from "../components/PaperReview";
|
||||||
|
import type { ReviewItem } from "../components/PaperReview";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
EditorDrawer,
|
EditorDrawer,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
Hint,
|
||||||
KnowledgeTags,
|
KnowledgeTags,
|
||||||
Panel,
|
|
||||||
RowActions,
|
RowActions,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
SubText,
|
SubText,
|
||||||
Toolbar,
|
|
||||||
rules,
|
rules,
|
||||||
useUi,
|
useUi,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
|
|
@ -50,6 +54,9 @@ interface QuestionBankProps {
|
||||||
preset?: { textbook_id?: number; chapter_id?: number } | null;
|
preset?: { textbook_id?: number; chapter_id?: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 题目浏览的两档视图:试卷卡片(默认)与管理员批量维护用的表格。 */
|
||||||
|
type BankView = "card" | "table";
|
||||||
|
|
||||||
export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) {
|
export default function QuestionBank({ onNavigate, preset }: QuestionBankProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
|
|
@ -62,6 +69,12 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
const [filterChapterId, setFilterChapterId] = useState<number | undefined>(
|
const [filterChapterId, setFilterChapterId] = useState<number | undefined>(
|
||||||
() => preset?.chapter_id,
|
() => preset?.chapter_id,
|
||||||
);
|
);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [difficulty, setDifficulty] = useState<number | undefined>(undefined);
|
||||||
|
const [usage, setUsage] = useState<"all" | "used" | "unused">("all");
|
||||||
|
/** 左侧书籍-章节树:默认折叠,只自动展开当前定位到的那本书。 */
|
||||||
|
const [openBooks, setOpenBooks] = useState<Set<number>>(() => new Set());
|
||||||
|
const [viewMode, setViewMode] = useState<BankView>("card");
|
||||||
const [genOpen, setGenOpen] = useState(false);
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
const [genBusy, setGenBusy] = useState(false);
|
const [genBusy, setGenBusy] = useState(false);
|
||||||
const [genTextbookId, setGenTextbookId] = useState<number | undefined>();
|
const [genTextbookId, setGenTextbookId] = useState<number | undefined>();
|
||||||
|
|
@ -74,9 +87,6 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
const [genForm] = Form.useForm();
|
const [genForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
|
|
||||||
const filterChapters =
|
|
||||||
textbooks.find((item) => item.id === filterTextbookId)?.chapters ?? [];
|
|
||||||
|
|
||||||
const listState = useApi(
|
const listState = useApi(
|
||||||
() =>
|
() =>
|
||||||
api.bankQuestions({
|
api.bankQuestions({
|
||||||
|
|
@ -85,6 +95,10 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
}),
|
}),
|
||||||
[filterTextbookId, filterChapterId],
|
[filterTextbookId, filterChapterId],
|
||||||
);
|
);
|
||||||
|
// 侧栏的「全部题目」必须是全库真实题量:各章 question_count 之和会漏掉
|
||||||
|
// 还没挂章节的题目,出现“全部题目 0 / 右栏共 18 题”这种自相矛盾。
|
||||||
|
const totalsState = useApi(api.bankSummary);
|
||||||
|
const totals = totalsState.data;
|
||||||
const llmState = useApi(api.llmSettings);
|
const llmState = useApi(api.llmSettings);
|
||||||
const graphState = useApi(api.knowledgeGraph);
|
const graphState = useApi(api.knowledgeGraph);
|
||||||
|
|
||||||
|
|
@ -95,6 +109,16 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
}
|
}
|
||||||
}, [preset?.textbook_id, preset?.chapter_id]);
|
}, [preset?.textbook_id, preset?.chapter_id]);
|
||||||
|
|
||||||
|
// 选中的书自动展开,其它书保持折叠,避免几十个章节一次性铺开
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterTextbookId) return;
|
||||||
|
setOpenBooks((prev) =>
|
||||||
|
prev.has(filterTextbookId)
|
||||||
|
? prev
|
||||||
|
: new Set(prev).add(filterTextbookId),
|
||||||
|
);
|
||||||
|
}, [filterTextbookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!genOpen || !llmState.data) return;
|
if (!genOpen || !llmState.data) return;
|
||||||
const defaultChannel = llmState.data.channels.find((channel) => channel.is_default);
|
const defaultChannel = llmState.data.channels.find((channel) => channel.is_default);
|
||||||
|
|
@ -124,9 +148,33 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
setGenOpen(true);
|
setGenOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFilterTextbookChange(textbookId?: number) {
|
function pickChapter(textbookId?: number, chapterId?: number) {
|
||||||
setFilterTextbookId(textbookId);
|
setFilterTextbookId(textbookId);
|
||||||
setFilterChapterId(undefined);
|
setFilterChapterId(chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBook(textbookId: number) {
|
||||||
|
setOpenBooks((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(textbookId)) next.delete(textbookId);
|
||||||
|
else next.add(textbookId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const booksWithChapters = textbooks.filter(
|
||||||
|
(book) => book.chapters.length > 0,
|
||||||
|
);
|
||||||
|
const allBooksOpen =
|
||||||
|
booksWithChapters.length > 0 &&
|
||||||
|
booksWithChapters.every((book) => openBooks.has(book.id));
|
||||||
|
|
||||||
|
function toggleAllBooks() {
|
||||||
|
setOpenBooks(
|
||||||
|
allBooksOpen
|
||||||
|
? new Set()
|
||||||
|
: new Set(booksWithChapters.map((book) => book.id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGenTextbookChange(textbookId: number) {
|
function handleGenTextbookChange(textbookId: number) {
|
||||||
|
|
@ -227,6 +275,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
if (next.status === "success") {
|
if (next.status === "success") {
|
||||||
message.success(backendText(next.message));
|
message.success(backendText(next.message));
|
||||||
listState.reload();
|
listState.reload();
|
||||||
|
textbooksState.reload();
|
||||||
|
totalsState.reload();
|
||||||
} else {
|
} else {
|
||||||
message.error(backendText(next.message));
|
message.error(backendText(next.message));
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +291,8 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
await api.deleteQuestion(item.id);
|
await api.deleteQuestion(item.id);
|
||||||
message.success(t("common.msgDeleted"));
|
message.success(t("common.msgDeleted"));
|
||||||
listState.reload();
|
listState.reload();
|
||||||
|
textbooksState.reload();
|
||||||
|
totalsState.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err instanceof Error ? err.message : t("common.msgDeleteFailed"));
|
message.error(err instanceof Error ? err.message : t("common.msgDeleteFailed"));
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +348,7 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
message.success(t("bank.msgUpdated", { id: updated.id }));
|
message.success(t("bank.msgUpdated", { id: updated.id }));
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
listState.reload();
|
listState.reload();
|
||||||
|
totalsState.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err instanceof Error ? err.message : t("common.msgSaveFailed"));
|
message.error(err instanceof Error ? err.message : t("common.msgSaveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -303,69 +356,40 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<QuestionBankItem> = [
|
const questions = useMemo(() => listState.data ?? [], [listState.data]);
|
||||||
{ title: "ID", dataIndex: "id", width: 70 },
|
|
||||||
{
|
/** 侧栏筛选 + 关键词,全部在本地完成:题目量级不大,切换要即时。 */
|
||||||
title: t("bank.colQuestion"),
|
const filtered = useMemo(() => {
|
||||||
dataIndex: "stem",
|
const text = keyword.trim().toLowerCase();
|
||||||
render: (value: string) => (
|
return questions.filter((item) => {
|
||||||
<span style={{ fontWeight: 600 }}>
|
if (difficulty && item.difficulty !== difficulty) return false;
|
||||||
<RichText content={value} compact />
|
if (usage === "used" && (item.used_count ?? 0) === 0) return false;
|
||||||
</span>
|
if (usage === "unused" && (item.used_count ?? 0) > 0) return false;
|
||||||
),
|
if (!text) return true;
|
||||||
},
|
return (
|
||||||
{
|
item.stem.toLowerCase().includes(text) ||
|
||||||
title: t("analytics.knowledge"),
|
item.explanation.toLowerCase().includes(text) ||
|
||||||
dataIndex: "knowledge_name",
|
String(item.id) === text
|
||||||
width: 220,
|
);
|
||||||
render: (_value: string, item) => (
|
});
|
||||||
<KnowledgeTags
|
}, [questions, keyword, difficulty, usage]);
|
||||||
names={
|
|
||||||
item.knowledge_names?.length > 0
|
const summary = useMemo(() => {
|
||||||
? item.knowledge_names
|
const used = filtered.filter((item) => (item.used_count ?? 0) > 0).length;
|
||||||
: [item.knowledge_name]
|
return { total: filtered.length, used };
|
||||||
}
|
}, [filtered]);
|
||||||
/>
|
|
||||||
),
|
const activeTextbook = textbooks.find((book) => book.id === filterTextbookId);
|
||||||
},
|
const activeChapter = activeTextbook?.chapters.find(
|
||||||
{
|
(chapter) => chapter.id === filterChapterId,
|
||||||
title: t("bank.colSource"),
|
);
|
||||||
key: "origin",
|
const scopeTitle = activeChapter
|
||||||
width: 120,
|
? `${activeTextbook?.name} · ${activeChapter.name}`
|
||||||
render: (_, item) => (
|
: activeTextbook
|
||||||
<Space orientation="vertical" size={4}>
|
? activeTextbook.name
|
||||||
<Tag color={item.is_generated ? tagColor.positive : tagColor.neutral}>
|
: t("bank.navAll");
|
||||||
{item.is_generated ? t("bank.generated") : t("bank.builtin")}
|
|
||||||
</Tag>
|
function questionActions(item: QuestionBankItem) {
|
||||||
<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 used = (item.used_count ?? 0) > 0;
|
const used = (item.used_count ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<RowActions
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel>
|
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -416,56 +561,9 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
{t("bank.generate")}
|
{t("bank.generate")}
|
||||||
</Button>
|
</Button>
|
||||||
</HeaderActions>
|
</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 && (
|
{activeTask && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div className="bank-task">
|
||||||
<SectionTitle>{t("bank.taskTitle", { id: activeTask.id })}</SectionTitle>
|
<SectionTitle>{t("bank.taskTitle", { id: activeTask.id })}</SectionTitle>
|
||||||
<Progress
|
<Progress
|
||||||
percent={activeTask.progress}
|
percent={activeTask.progress}
|
||||||
|
|
@ -480,6 +578,221 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
<SubText>{backendText(activeTask.message)}</SubText>
|
<SubText>{backendText(activeTask.message)}</SubText>
|
||||||
</div>
|
</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
|
<AsyncState
|
||||||
loading={listState.loading}
|
loading={listState.loading}
|
||||||
error={listState.error}
|
error={listState.error}
|
||||||
|
|
@ -488,35 +801,22 @@ export default function QuestionBank({ onNavigate, preset }: QuestionBankProps)
|
||||||
{listState.data && (
|
{listState.data && (
|
||||||
<DataTable<QuestionBankItem>
|
<DataTable<QuestionBankItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={tableColumns}
|
||||||
scroll={{ x: 1240 }}
|
scroll={{ x: 1180 }}
|
||||||
dataSource={listState.data}
|
dataSource={filtered}
|
||||||
locale={{
|
locale={{ emptyText: emptyHint }}
|
||||||
emptyText: (
|
pagination={{
|
||||||
<EmptyState
|
pageSize: 20,
|
||||||
description={
|
showSizeChanger: true,
|
||||||
filterTextbookId
|
showTotal: (total) => t("common.totalRows", { n: total }),
|
||||||
? 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}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditorDrawer
|
<EditorDrawer
|
||||||
title={t("bank.editTitle", { id: editItem?.id ?? "" })}
|
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 { api } from "../api/client";
|
||||||
import { AsyncState } from "../components/Async";
|
import { AsyncState } from "../components/Async";
|
||||||
import { QuestionDetail } from "../components/QuestionDetail";
|
import { Pane, SplitView } from "../components/SplitView";
|
||||||
|
import { PaperMetaText, PaperPlaceholder, PaperReview, ScoreChip } from "../components/PaperReview";
|
||||||
|
import type { ReviewItem } from "../components/PaperReview";
|
||||||
import { useApi } from "../components/useApi";
|
import { useApi } from "../components/useApi";
|
||||||
import {
|
import { EmptyState } from "../components/ui";
|
||||||
DataTable,
|
import type { ViewId } from "../nav";
|
||||||
EmptyState,
|
|
||||||
Hint,
|
|
||||||
KnowledgeTags,
|
|
||||||
Panel,
|
|
||||||
SubText,
|
|
||||||
Toolbar,
|
|
||||||
} from "../components/ui";
|
|
||||||
import { useT } from "../prefs";
|
import { useT } from "../prefs";
|
||||||
import { tagColor } from "../theme";
|
import { tagColor } from "../theme";
|
||||||
import type { AttemptItemDetail, RecordDetail, RecordItem } from "../types";
|
import type { RecordDetail, RecordItem } from "../types";
|
||||||
|
|
||||||
function ItemView({ item }: { item: AttemptItemDetail }) {
|
interface RecordsProps {
|
||||||
const t = useT();
|
onNavigate?: (view: ViewId) => void;
|
||||||
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size={10} style={{ width: "100%" }}>
|
|
||||||
<Space wrap size={[4, 4]}>
|
|
||||||
<Tag color={item.correct ? tagColor.positive : tagColor.negative}>
|
|
||||||
{item.correct ? t("records.correct") : t("records.incorrect")}
|
|
||||||
</Tag>
|
|
||||||
<KnowledgeTags
|
|
||||||
names={
|
|
||||||
item.knowledge_names?.length
|
|
||||||
? item.knowledge_names
|
|
||||||
: [item.knowledge_name]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<QuestionDetail
|
|
||||||
stem={item.stem}
|
|
||||||
options={item.options}
|
|
||||||
selected={item.selected}
|
|
||||||
correctIndex={item.correct_index}
|
|
||||||
explanation={item.explanation}
|
|
||||||
revealCorrect={item.correct === false}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Records() {
|
function accuracyOf(record: { correct: number; total: number }) {
|
||||||
|
return record.total ? Math.round((record.correct / record.total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Records({ onNavigate }: RecordsProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
const { data, loading, error, reload } = useApi(api.records);
|
const listState = useApi(api.records);
|
||||||
|
const records = useMemo(() => listState.data ?? [], [listState.data]);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [details, setDetails] = useState<Record<number, RecordDetail>>({});
|
const [details, setDetails] = useState<Record<number, RecordDetail>>({});
|
||||||
const [loadingId, setLoadingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [detailErrors, setDetailErrors] = useState<Record<number, string>>({});
|
const [detailErrors, setDetailErrors] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
async function loadDetail(record: RecordItem) {
|
const visible = useMemo(() => {
|
||||||
if (details[record.id] || loadingId !== null) return;
|
const text = keyword.trim().toLowerCase();
|
||||||
setLoadingId(record.id);
|
if (!text) return records;
|
||||||
|
return records.filter(
|
||||||
|
(record) =>
|
||||||
|
record.title.toLowerCase().includes(text) ||
|
||||||
|
record.happened_on.toLowerCase().includes(text),
|
||||||
|
);
|
||||||
|
}, [records, keyword]);
|
||||||
|
|
||||||
|
// 默认选中最近一次训练,进页面就能看到卷子
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === null && records.length > 0) {
|
||||||
|
setSelectedId(records[0].id);
|
||||||
|
}
|
||||||
|
}, [records, selectedId]);
|
||||||
|
|
||||||
|
async function loadDetail(id: number) {
|
||||||
|
if (details[id] || pendingId === id) return;
|
||||||
|
setPendingId(id);
|
||||||
setDetailErrors((prev) => {
|
setDetailErrors((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[record.id];
|
delete next[id];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const detail = await api.recordDetail(record.id);
|
const detail = await api.recordDetail(id);
|
||||||
setDetails((prev) => ({ ...prev, [record.id]: detail }));
|
setDetails((prev) => ({ ...prev, [id]: detail }));
|
||||||
} catch (err) {
|
} catch {
|
||||||
setDetailErrors((prev) => ({
|
setDetailErrors((prev) => ({ ...prev, [id]: t("common.loadFailed") }));
|
||||||
...prev,
|
|
||||||
[record.id]: t("common.loadFailed"),
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingId(null);
|
setPendingId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<RecordItem> = [
|
useEffect(() => {
|
||||||
{ title: t("records.colTime"), dataIndex: "happened_on", width: 130 },
|
if (selectedId !== null) void loadDetail(selectedId);
|
||||||
{ title: t("records.colTraining"), dataIndex: "title" },
|
// 选中变化时补拉明细,其余依赖都是稳定引用
|
||||||
{ title: t("records.colTotal"), dataIndex: "total", width: 90, align: "center" },
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
{ title: t("records.colCorrect"), dataIndex: "correct", width: 90, align: "center" },
|
}, [selectedId]);
|
||||||
{
|
|
||||||
title: t("records.colRate"),
|
const selected = selectedId === null ? null : (details[selectedId] ?? null);
|
||||||
key: "accuracy",
|
const selectedError = selectedId === null ? null : detailErrors[selectedId];
|
||||||
width: 240,
|
const loadingSelected =
|
||||||
render: (_: unknown, record) => {
|
selectedId !== null && !selected && !selectedError && pendingId === selectedId;
|
||||||
const percent = record.total
|
|
||||||
? Math.round((record.correct / record.total) * 100)
|
const items: ReviewItem[] = (selected?.items ?? []).map((item) => ({
|
||||||
: 0;
|
key: `${item.question_id ?? "q"}-${item.question_index}`,
|
||||||
|
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 (
|
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
|
<Progress
|
||||||
percent={percent}
|
percent={percent}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 120, margin: 0 }}
|
showInfo={false}
|
||||||
|
style={{ marginTop: 6, marginBottom: 0 }}
|
||||||
/>
|
/>
|
||||||
{record.total - record.correct > 0 ? (
|
</button>
|
||||||
<Tag color={tagColor.negative}>{t("records.wrongN", { n: record.total - record.correct })}</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color={tagColor.positive}>{t("records.allCorrect")}</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
);
|
);
|
||||||
},
|
})}
|
||||||
},
|
</Pane>
|
||||||
];
|
|
||||||
|
|
||||||
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 />;
|
|
||||||
}
|
}
|
||||||
if (detailErrors[record.id]) {
|
main={
|
||||||
return (
|
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
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
title={detailErrors[record.id]}
|
title={selectedError}
|
||||||
action={
|
action={
|
||||||
<Button
|
selectedId !== null && (
|
||||||
size="small"
|
<Button size="small" danger onClick={() => void loadDetail(selectedId)}>
|
||||||
danger
|
|
||||||
onClick={() => void loadDetail(record)}
|
|
||||||
>
|
|
||||||
{t("common.retry")}
|
{t("common.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PaperPlaceholder
|
||||||
|
description={
|
||||||
|
records.length === 0 && onNavigate
|
||||||
|
? t("records.empty")
|
||||||
|
: t("records.pickOne")
|
||||||
}
|
}
|
||||||
const detail = details[record.id];
|
action={
|
||||||
if (!detail) {
|
records.length === 0 && onNavigate ? (
|
||||||
return <SubText>{t("records.detailPending")}</SubText>;
|
<Flex justify="center">
|
||||||
|
<Button type="primary" onClick={() => onNavigate("test")}>
|
||||||
|
{t("records.startTest")}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
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: #f1f2f6;
|
||||||
--track-raised: #ffffff;
|
--track-raised: #ffffff;
|
||||||
--brand-soft: rgba(91, 92, 226, 0.08);
|
--brand-soft: rgba(91, 92, 226, 0.08);
|
||||||
|
--select-bg: var(--brand-soft);
|
||||||
|
--select-border: rgba(91, 92, 226, 0.45);
|
||||||
|
--select-glow: 0 0 0 3px rgba(91, 92, 226, 0.12);
|
||||||
--success-soft: #f0fdf4;
|
--success-soft: #f0fdf4;
|
||||||
--success-soft-border: #bbf7d0;
|
--success-soft-border: #bbf7d0;
|
||||||
--danger-soft: #fef2f2;
|
--danger-soft: #fef2f2;
|
||||||
|
|
@ -47,6 +50,9 @@
|
||||||
--track: #24263c;
|
--track: #24263c;
|
||||||
--track-raised: #3a3e5c;
|
--track-raised: #3a3e5c;
|
||||||
--brand-soft: rgba(91, 92, 226, 0.22);
|
--brand-soft: rgba(91, 92, 226, 0.22);
|
||||||
|
--select-bg: var(--brand-soft);
|
||||||
|
--select-border: rgba(129, 130, 235, 0.55);
|
||||||
|
--select-glow: 0 0 0 3px rgba(129, 130, 235, 0.18);
|
||||||
--success-soft: rgba(22, 163, 74, 0.14);
|
--success-soft: rgba(22, 163, 74, 0.14);
|
||||||
--success-soft-border: rgba(22, 163, 74, 0.4);
|
--success-soft-border: rgba(22, 163, 74, 0.4);
|
||||||
--danger-soft: rgba(220, 38, 38, 0.14);
|
--danger-soft: rgba(220, 38, 38, 0.14);
|
||||||
|
|
@ -606,6 +612,13 @@ body {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-toolbar-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -735,6 +748,22 @@ body {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中项与全站同一套「浅底 + 品牌描边」;右侧的对勾保证一眼可辨 */
|
||||||
|
.option-row--active,
|
||||||
|
.option-row--active:hover,
|
||||||
|
.option-row--active:focus {
|
||||||
|
background: var(--select-bg) !important;
|
||||||
|
border-color: var(--select-border) !important;
|
||||||
|
color: var(--text-strong) !important;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--select-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-row-check {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
padding-inline-start: 10px;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
/* 答题题干:练习流程统一排版 */
|
/* 答题题干:练习流程统一排版 */
|
||||||
.question-stem {
|
.question-stem {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
|
@ -779,6 +808,281 @@ body {
|
||||||
min-width: min(520px, 100%);
|
min-width: min(520px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 知识图谱画布(知识地图 / 图谱管理共用) ---------------- */
|
||||||
|
|
||||||
|
.kg-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-zoom {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-zoom-value {
|
||||||
|
min-width: 42px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-canvas {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background-image: radial-gradient(var(--border-soft) 1px, transparent 1px);
|
||||||
|
background-size: 22px 22px;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-content {
|
||||||
|
position: relative;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edges {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border-soft);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
transition: opacity 0.18s ease, stroke 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--前置 {
|
||||||
|
stroke: rgba(91, 92, 226, 0.5);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--相关 {
|
||||||
|
stroke-dasharray: 2 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--包含 {
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--focus {
|
||||||
|
stroke: var(--brand);
|
||||||
|
stroke-width: 2.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--faded {
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-edge--dim {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-arrow-head {
|
||||||
|
fill: rgba(91, 92, 226, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 域标题:一条横贯本域所有分类的细线 + 标题 */
|
||||||
|
.kg-band {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-band::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-band-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-column-head {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.16s ease, background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-column-head:hover {
|
||||||
|
border-color: var(--select-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-column-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-column-meta {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.16s ease,
|
||||||
|
box-shadow 0.16s ease,
|
||||||
|
background 0.16s ease,
|
||||||
|
opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node:hover {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node-dot {
|
||||||
|
flex: none;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--node-tone, var(--border-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--practiced {
|
||||||
|
border-color: var(--select-border);
|
||||||
|
border-color: color-mix(in srgb, var(--node-tone) 45%, var(--border-soft));
|
||||||
|
background: var(--brand-soft);
|
||||||
|
background: color-mix(in srgb, var(--node-tone) 8%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--unpracticed .kg-node-dot {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--unpracticed .kg-node-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node-mastery {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--selected {
|
||||||
|
border-color: var(--select-border);
|
||||||
|
background: var(--select-bg);
|
||||||
|
box-shadow: var(--select-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--faded {
|
||||||
|
opacity: 0.32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-node--dim {
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-legend-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kg-legend-dot--empty {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.graph-detail {
|
.graph-detail {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -786,12 +1090,13 @@ body {
|
||||||
top: 76px;
|
top: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-box {
|
/* 树视图:和画布同一块高度,长了在自己内部滚 */
|
||||||
max-height: 620px;
|
.graph-tree {
|
||||||
|
max-height: 560px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -893,11 +1198,11 @@ body {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选中项:白底小块浮在面板上,底色与右侧内容一致 */
|
/* 选中项:与其他地方的「选中」同一套语义(浅品牌底 + 品牌描边) */
|
||||||
.resource-tabs .ant-tabs-tab-active,
|
.resource-tabs .ant-tabs-tab-active,
|
||||||
.resource-tabs .ant-tabs-tab-active:hover {
|
.resource-tabs .ant-tabs-tab-active:hover {
|
||||||
background: var(--surface);
|
background: var(--select-bg);
|
||||||
box-shadow: 0 1px 3px rgba(15, 17, 45, 0.14);
|
box-shadow: inset 0 0 0 1px var(--select-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
|
.resource-tabs .ant-tabs-tab-active .ant-tabs-tab-btn,
|
||||||
|
|
@ -1258,9 +1563,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-rate--active {
|
.daily-rate--active {
|
||||||
background: var(--brand-soft);
|
background: var(--select-bg);
|
||||||
border-color: var(--brand);
|
border-color: var(--select-border);
|
||||||
box-shadow: inset 0 0 0 1px var(--brand);
|
box-shadow: inset 0 0 0 1px var(--select-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-rate-icon {
|
.daily-rate-icon {
|
||||||
|
|
@ -1510,8 +1815,8 @@ body.split-dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
.master-item--active {
|
.master-item--active {
|
||||||
background: var(--brand-soft);
|
background: var(--select-bg);
|
||||||
border-color: rgba(91, 92, 226, 0.35);
|
border-color: var(--select-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.master-item-title {
|
.master-item-title {
|
||||||
|
|
@ -1640,8 +1945,9 @@ body.split-dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper-question--active {
|
.paper-question--active {
|
||||||
background: var(--surface-2);
|
background: var(--select-bg);
|
||||||
box-shadow: inset 3px 0 0 var(--brand);
|
/* 整圈细描边取代原来的左侧指示条:卡片感更强,也不偏不倚 */
|
||||||
|
box-shadow: inset 0 0 0 1px var(--select-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper-question-rail {
|
.paper-question-rail {
|
||||||
|
|
@ -1765,8 +2071,8 @@ body.split-dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-chip--active {
|
.answer-chip--active {
|
||||||
border-color: var(--brand);
|
border-color: var(--select-border);
|
||||||
box-shadow: 0 0 0 2px var(--brand-soft);
|
box-shadow: var(--select-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-chip-icon {
|
.answer-chip-icon {
|
||||||
|
|
@ -1786,7 +2092,8 @@ body.split-dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-chip--done {
|
.answer-chip--done {
|
||||||
border-color: rgba(91, 92, 226, 0.45);
|
background: var(--select-bg);
|
||||||
|
border-color: var(--select-border);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1795,59 +2102,62 @@ body.split-dragging {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-sheet-legend {
|
.answer-sheet-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-sheet-pager-text {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 答题侧栏的「当前题」卡片:滚动到哪一题、这题答没答 */
|
||||||
|
.sheet-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 12px;
|
padding: 10px 12px;
|
||||||
color: var(--text-secondary);
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-sheet-legend-toggle {
|
.sheet-current-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-current-state {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-current-state--done {
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-jump {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
text-decoration: underline dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-sheet-legend-items {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-sheet-legend-items span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-dot {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-dot--correct {
|
|
||||||
background: var(--success-soft);
|
|
||||||
border: 1px solid #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-dot--wrong {
|
|
||||||
background: var(--danger-soft);
|
|
||||||
border: 1px solid #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-dot--done {
|
|
||||||
background: var(--brand-soft);
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer-dot--pending {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px dashed var(--text-muted);
|
color: var(--brand);
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-jump:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-sheet-summary {
|
.answer-sheet-summary {
|
||||||
|
|
@ -1913,30 +2223,135 @@ body.split-dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 清单里的可点分组(教材名)与更紧凑的子项(章节) */
|
/* 清单里的可点分组(教材名)与更紧凑的子项(章节) */
|
||||||
.master-group-button {
|
.master-tree-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
width: 100%;
|
gap: 8px;
|
||||||
padding: 5px 8px;
|
margin: 4px 0 6px;
|
||||||
border: 0;
|
padding: 0 8px;
|
||||||
border-radius: 8px;
|
font-size: 12px;
|
||||||
background: transparent;
|
font-weight: 600;
|
||||||
color: var(--text-strong);
|
color: var(--text-muted);
|
||||||
font: inherit;
|
|
||||||
text-align: start;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.16s ease, color 0.16s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.master-group-button:hover {
|
.master-tree-toggle {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brand);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-tree-toggle:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一本书:一行标题 + 折叠起来的章节列表 */
|
||||||
|
.master-book {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-book-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-book-row:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.master-group-button--active {
|
.master-book-row--active {
|
||||||
|
background: var(--select-bg);
|
||||||
|
border-color: var(--select-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-caret {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-inline-start: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-caret:hover:not(:disabled) {
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.master-caret:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-caret-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-book-button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px 6px 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-book-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 章节缩进 + 左侧引导线,和书名的层级一眼分得清 */
|
||||||
|
.master-chapters {
|
||||||
|
margin: 2px 0 6px 14px;
|
||||||
|
padding-inline-start: 8px;
|
||||||
|
border-inline-start: 1px solid var(--scrollbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-chapter-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-item--zero .master-item-title,
|
||||||
|
.master-item--zero .chapter-nav-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-book-empty {
|
||||||
|
margin: 0 0 4px 26px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.master-item--tight {
|
.master-item--tight {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
@ -2032,6 +2447,10 @@ body.split-dragging {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-launcher-count {
|
||||||
|
width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
.test-launcher-meta {
|
.test-launcher-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,15 @@ export interface QuestionBankItem {
|
||||||
knowledge_ids: number[];
|
knowledge_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 题库侧栏计数:全库真实题量,与按章节累加的 question_count 不同。 */
|
||||||
|
export interface QuestionBankSummary {
|
||||||
|
total: number;
|
||||||
|
unattached: number;
|
||||||
|
generated: number;
|
||||||
|
used: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface QuestionUpdatePayload {
|
export interface QuestionUpdatePayload {
|
||||||
stem: string;
|
stem: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
|
|
@ -359,6 +368,25 @@ export interface KnowledgeGraphData {
|
||||||
edges: KnowledgeGraphEdge[];
|
edges: KnowledgeGraphEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 学生视角的知识节点:结构 + 本人掌握度(未练过的也返回,practiced 为假)。 */
|
||||||
|
export interface KnowledgeMapNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
mastery: number;
|
||||||
|
status: string;
|
||||||
|
practiced: boolean;
|
||||||
|
chapter_count: number;
|
||||||
|
question_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeMapData {
|
||||||
|
nodes: KnowledgeMapNode[];
|
||||||
|
edges: KnowledgeGraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface KnowledgeNodeCreatePayload {
|
export interface KnowledgeNodeCreatePayload {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|
|
||||||
121
scripts/init.sh
121
scripts/init.sh
|
|
@ -1,33 +1,112 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 初始化:安装后端依赖并写入种子数据(data/math.db)。
|
# 初始化本机运行环境:Python 虚拟环境 + 后端依赖 + 种子数据(+ 前端依赖)。
|
||||||
|
#
|
||||||
|
# ./scripts/init.sh 准备后端与前端环境
|
||||||
|
# ./scripts/init.sh --no-web 只准备后端
|
||||||
|
#
|
||||||
|
# 可用环境变量:
|
||||||
|
# PIP_INDEX_URL 指定 pip/uv 的包源(默认走官方源,失败自动改用清华镜像)
|
||||||
|
# NPM_REGISTRY 指定 npm 包源(失败自动改用 npmmirror)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
|
||||||
if [[ ! -x backend/.venv/bin/python ]]; then
|
WITH_WEB=1
|
||||||
if command -v uv >/dev/null 2>&1; then
|
for arg in "$@"; do
|
||||||
echo "[init] 使用 uv 创建 Python 虚拟环境…"
|
case "$arg" in
|
||||||
(cd backend && uv venv .venv --python 3.12)
|
--no-web) WITH_WEB=0 ;;
|
||||||
else
|
--web) WITH_WEB=1 ;;
|
||||||
if python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then
|
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
|
||||||
echo "[init] 使用 python3 创建虚拟环境…"
|
*) echo "[init] 未知参数:$arg(可用:--no-web / --web)" >&2; exit 2 ;;
|
||||||
python3 -m venv backend/.venv
|
esac
|
||||||
else
|
done
|
||||||
echo "[init] 未找到 Python 3.10+,请安装 uv(https://docs.astral.sh/uv/)后重试。" >&2
|
|
||||||
|
# ---------- 1. 定位 uv(PATH → ~/.local/bin → ~/.cargo/bin) ----------
|
||||||
|
UV=""
|
||||||
|
for cand in uv "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||||
|
if command -v "$cand" >/dev/null 2>&1; then
|
||||||
|
UV="$(command -v "$cand")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 2. 定位 Python 3.10+(系统 python3 常常太旧,优先带版本号的解释器) ----------
|
||||||
|
py_ok() { "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
pick_python() {
|
||||||
|
for p in python3.13 python3.12 python3.11 python3.10 python3; do
|
||||||
|
if command -v "$p" >/dev/null 2>&1 && py_ok "$p"; then command -v "$p"; return 0; fi
|
||||||
|
done
|
||||||
|
for p in /opt/homebrew/bin/python3.1* /usr/local/bin/python3.1*; do
|
||||||
|
if [ -x "$p" ] && py_ok "$p"; then echo "$p"; return 0; fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 3. 虚拟环境 ----------
|
||||||
|
if [ -x backend/.venv/bin/python ]; then
|
||||||
|
echo "[init] 复用已有虚拟环境 backend/.venv($(backend/.venv/bin/python -V 2>&1))"
|
||||||
|
elif [ -n "$UV" ]; then
|
||||||
|
echo "[init] 使用 uv($UV)创建虚拟环境,优先 CPython 3.12…"
|
||||||
|
(cd backend && "$UV" venv .venv --python 3.12) 2>/dev/null || (cd backend && "$UV" venv .venv)
|
||||||
|
else
|
||||||
|
PY="$(pick_python || true)"
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
echo "[init] 未找到 Python 3.10+(系统自带的 python3 版本过低)。推荐安装 uv 后重试:" >&2
|
||||||
|
echo " curl -LsSf https://astral.sh/uv/install.sh | sh" >&2
|
||||||
|
echo " 或 brew install python@3.12" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "[init] 使用 $("$PY" -V 2>&1)($PY)创建虚拟环境…"
|
||||||
|
"$PY" -m venv backend/.venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 4. 后端依赖(默认源失败后自动改用国内镜像) ----------
|
||||||
|
install_backend() {
|
||||||
|
if [ -n "$UV" ]; then
|
||||||
|
(cd backend && "$UV" pip install --python .venv/bin/python ${1:+--index-url "$1"} -r requirements.txt)
|
||||||
|
else
|
||||||
|
(cd backend && .venv/bin/pip install ${1:+-i "$1"} -r requirements.txt)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! install_backend "${PIP_INDEX_URL:-}"; then
|
||||||
|
echo "[init] 默认包源安装失败,改用清华镜像重试…" >&2
|
||||||
|
install_backend https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 5. 种子数据 ----------
|
||||||
|
if [ -f data/math.db ]; then
|
||||||
|
echo "[init] data/math.db 已存在,跳过种子数据(重建:cd backend && .venv/bin/python -m seed --force)"
|
||||||
|
else
|
||||||
|
(cd backend && .venv/bin/python -m seed)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 6. 前端依赖 ----------
|
||||||
|
if [ "$WITH_WEB" -eq 1 ]; then
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "[init] 未找到 Node.js,前端跳过。请安装 Node 20.19+ 或 22.12+ 后执行:cd frontend && npm install" >&2
|
||||||
|
else
|
||||||
|
if [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
|
||||||
|
echo "[init] 当前 Node $(node -v) 过低,Vite 8 需要 Node 20.19+ / 22.12+,前端可能无法启动。" >&2
|
||||||
|
fi
|
||||||
|
npm_install() {
|
||||||
|
if [ -n "${1:-}" ]; then (cd frontend && npm install --registry "$1"); else (cd frontend && npm install); fi
|
||||||
|
}
|
||||||
|
if [ -d frontend/node_modules ]; then
|
||||||
|
echo "[init] 前端依赖已存在,跳过 npm install"
|
||||||
|
elif ! npm_install "${NPM_REGISTRY:-}"; then
|
||||||
|
echo "[init] 默认 npm 源安装失败,改用 npmmirror 重试…" >&2
|
||||||
|
npm_install https://registry.npmmirror.com
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v uv >/dev/null 2>&1; then
|
echo
|
||||||
uv pip install --python backend/.venv/bin/python -r backend/requirements.txt
|
echo "[init] 环境就绪:"
|
||||||
else
|
echo " Python $(backend/.venv/bin/python -V 2>&1) → backend/.venv"
|
||||||
backend/.venv/bin/pip install -r backend/requirements.txt
|
echo " Node $(node -v 2>/dev/null || echo '未安装')"
|
||||||
fi
|
echo " 数据库 $ROOT_DIR/data/math.db"
|
||||||
|
echo "[init] 启动:./scripts/start.sh → http://localhost:5173"
|
||||||
(cd backend && .venv/bin/python -m seed)
|
|
||||||
|
|
||||||
echo "[init] 完成。启动方式:./scripts/start.sh"
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 同时启动后端(FastAPI :8000)与前端(Vite :5173)。
|
# 同时启动后端(FastAPI :8000)与前端(Vite :5173)。
|
||||||
|
# 端口可用 BACKEND_PORT / FRONTEND_PORT 覆盖。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||||
|
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
|
||||||
|
|
||||||
if [[ ! -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]]; then
|
if [[ ! -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]]; then
|
||||||
echo "尚未初始化,请先运行 ./scripts/init.sh" >&2
|
echo "尚未初始化,请先运行 ./scripts/init.sh" >&2
|
||||||
|
|
@ -12,6 +15,24 @@ if [[ ! -f "$ROOT_DIR/data/math.db" ]]; then
|
||||||
echo "数据库不存在,请先运行 ./scripts/init.sh" >&2
|
echo "数据库不存在,请先运行 ./scripts/init.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "未找到 Node.js,前端无法启动。请安装 Node 20.19+ / 22.12+(或先只跑后端:cd backend && .venv/bin/uvicorn main:app --reload)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 端口占用早失败,避免 Vite 报一堆堆栈
|
||||||
|
require_free_port() {
|
||||||
|
local port="$1" name="$2"
|
||||||
|
command -v lsof >/dev/null 2>&1 || return 0
|
||||||
|
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "端口 $port($name)已被占用:" >&2
|
||||||
|
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
|
||||||
|
echo "请先停掉占用进程,或用 ${name}_PORT=其他端口 ./scripts/start.sh 换端口。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
require_free_port "$BACKEND_PORT" BACKEND
|
||||||
|
require_free_port "$FRONTEND_PORT" FRONTEND
|
||||||
|
|
||||||
if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
|
if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
|
||||||
echo "[start] 安装前端依赖…"
|
echo "[start] 安装前端依赖…"
|
||||||
|
|
@ -26,11 +47,11 @@ cleanup() {
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
echo "[start] 后端:http://localhost:${BACKEND_PORT:-8000}/api/health"
|
echo "[start] 后端:http://localhost:${BACKEND_PORT}/api/health"
|
||||||
(cd "$ROOT_DIR/backend" && exec .venv/bin/uvicorn main:app \
|
(cd "$ROOT_DIR/backend" && exec .venv/bin/uvicorn main:app \
|
||||||
--host 0.0.0.0 --port "${BACKEND_PORT:-8000}" --reload) &
|
--host 0.0.0.0 --port "${BACKEND_PORT}" --reload) &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
echo "[start] 前端:http://localhost:${FRONTEND_PORT:-5173}"
|
echo "[start] 前端:http://localhost:${FRONTEND_PORT}"
|
||||||
(cd "$ROOT_DIR/frontend" && exec npm run dev -- \
|
(cd "$ROOT_DIR/frontend" && exec npm run dev -- \
|
||||||
--host 0.0.0.0 --port "${FRONTEND_PORT:-5173}")
|
--host 0.0.0.0 --port "${FRONTEND_PORT}")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue