Compare commits

...

2 Commits

Author SHA1 Message Date
mula.liu c06ca75c89 内容页边距:宽度优先给内容,顶栏与内容列同一条基线
高分辨屏上内容列被写死的 1360px 夹在中间,两侧大片空白,
顶栏控件还留在窗口边缘,和内容列差出一百多像素。

* 新增布局变量 --layout-header-h / --content-pad / --content-pad-end /
  --content-max,边距一处可调:14px 起,≥900 20px、≥1600 24px、≥2200 28px
* 内容列上限 1360 → 1680,宽度优先给内容;顶栏 padding-inline 跟着
  内容列内收(max(pad, (100% - 上限)/2)),1920/2560 下右缘零偏差
* 底部留白单独成变量,阅读器高度按 100vh - 顶栏 - 上下留白计算,
  移动端 768 的写死高度一并变量化,去掉多余的几像素竖直滚动
* 试卷排版收紧行距与段距;同时带上上一轮未提交的试卷/分屏样式
2026-09-18 14:56:36 +08:00
mula.liu 5f72149ceb 数据可恢复:补齐章节目录、找回电子书、加备份与内容快照
库文件 data/*.db 一直不进 git,seed --force 之后章节和题目就跟着没了。
这次把内容找回来,并且让远端始终留有一份可恢复的快照。

* seed/catalog.py:预置 4 本书的正式章节目录(29 章),启动时按 ISBN
  补齐缺失章节,并把未归章的题目挂到对应章下(不覆盖人工改过的归章)
* seed/ebooks.py:按「大小 + SHA-256」认领 data/ebooks 里的孤儿电子书,
  重建教材条目并把文件挪回该书目录(已找回教材 7《柯西-施瓦茨大师课》)
* services/ebooks.py:同内容文件复用,避免种子重复复制电子书
* dbtool.py + scripts/db.sh:backup/restore 本机时间戳备份,
  dump/load 把内容表导成 data/dump/catalog.sql(可提交、可灌回空库)
* seed --force 前自动备份并打印路径;README 增加「数据备份与恢复」
2026-09-18 14:56:10 +08:00
12 changed files with 1636 additions and 110 deletions

1
.gitignore vendored
View File

@ -6,4 +6,5 @@ node_modules/
frontend/dist/ frontend/dist/
.DS_Store .DS_Store
data/*.db data/*.db
data/backups/
.env .env

135
README.md
View File

@ -27,15 +27,22 @@ nex_math/
│ ├── schemas/ # Pydantic 请求响应模型 │ ├── schemas/ # Pydantic 请求响应模型
│ ├── routers/ # overview / practice / data / analytics / archive / catalog / question-bank / llm │ ├── routers/ # overview / practice / data / analytics / archive / catalog / question-bank / llm
│ ├── services/ # llm_client / question_generator / ai_teacher / question_engine / mastery_engine / recommendation │ ├── services/ # llm_client / question_generator / ai_teacher / question_engine / mastery_engine / recommendation
│ └── seed/ # 目录与题库种子数据 │ ├── seed/ # 目录与题库种子数据
│ └── dbtool.py # 数据库备份 / 恢复 / 内容快照(python -m dbtool)
├── frontend/ # Vite + React 前端 ├── frontend/ # Vite + React 前端
│ └── src/ │ └── src/
│ ├── pages/ # 九个页面 │ ├── pages/ # 学习端 9 个页面 + 管理端页面 + 登录 / 电子书阅读器
│ ├── components/ │ ├── components/ # SplitView / Pane(分屏工作台)、Paper(答题卡·题块·解析)、
│ │ # PaperReview(整卷回看)、useApi、ui.tsx 等
│ ├── api/ # API 客户端 │ ├── api/ # API 客户端
│ ├── theme.ts # 设计令牌:标签色 / 状态色 / 掌握度色
│ ├── i18n.ts # 中英双语文案(新增键必须 zh、en 同时补)
│ └── types/ │ └── types/
├── data/math.db # SQLite 数据库(首次初始化后生成) ├── data/ # math.db(本机库,被 gitignore)
└── scripts/ # init.sh / start.sh │ ├── ebooks/ # 教材电子书(随仓库提交)
│ ├── backups/ # db.sh backup 生成的时间戳备份(gitignore)
│ └── dump/ # catalog.sql:内容快照,提交进仓库=远端存了一份库
└── scripts/ # init.sh(建环境)/ start.sh(起服务)/ db.sh(备份与恢复)
``` ```
## 界面与导航 ## 界面与导航
@ -49,10 +56,35 @@ nex_math/
- 全站样式与控件收敛到三层:**theme.ts(设计令牌)→ ui.tsx(Page / Panel / - 全站样式与控件收敛到三层:**theme.ts(设计令牌)→ ui.tsx(Page / Panel /
Toolbar / DataTable / RowActions / MetricGrid / CountTag / EmptyState 等) Toolbar / DataTable / RowActions / MetricGrid / CountTag / EmptyState 等)
→ 页面**,新页面只组合统一控件,不各写一套尺寸与颜色。 → 页面**,新页面只组合统一控件,不各写一套尺寸与颜色。
- **题库管理 / 练习记录 / 错题本 / 章节测试采用「清单 + 试卷」分屏工作台**
(`components/SplitView.tsx` + `Pane`):一栏是可搜索的清单,另一栏按真实试卷版式
排题目;两栏各自独立滚动,清单不会把试卷顶走,整页不再是一条长滚动条。
- 题库管理:左「教材→章节」树 + 搜索/难度/来源/使用状态筛选,右栏题目预览卷;
侧栏首行显示题库总数,并提示还有多少题没挂到章节(计数来自 `/api/question-bank/summary`)。
- 练习记录:左练习清单(得分进度条 + 错题数徽标),右该次练习的完整试卷回看,
顶部答题卡按题号标色(正确 / 错误 / 未答),点题号跳题,可切「全部 / 只看错题」与「展开全部解析」。
- 错题本:左错题清单(按知识点筛选、搜索题干),右错题精讲,逐题对照
我的作答 / 正确答案 / 解析,底部上一题、下一题翻页。
- 章节测试:试卷在左,答题卡与本次小结在右,答题中随时点题号跳题、查看已答未答;
交卷后同一套布局直接变成判卷结果。
- 中缝可拖拽调节清单栏宽度(248–560px),偏好写入 localStorage
(`nex_math.split.<bank|records|errors|test>.aside`);窄屏(<992px)自动改为上下堆叠。
- 章节测试按章节组卷,前置条件是**教材有章节目录、章节下挂了题目**。缺任何一步都只给
引导空态,不会变成红色报错,也不会写出 0 题的记录:
- 教材还没有章节 → 提示请管理员在「教材管理」中添加教材、整理章节目录;
- 选了还没挂题的章节 → 组出的是空卷,试卷位置显示「请管理员在题库管理里把题目关联到
本章」,答题卡提示本章暂无题目,提交按钮禁用;
- 章节题量不足 5 题时,组卷条件那行挂黄色徽标提示实际题量。
## 快速开始(本机) ## 快速开始(本机)
要求:Python 3.10+(推荐使用 [uv](https://docs.astral.sh/uv/))、Node.js 18+。 要求:
- Python 3.10+。macOS 自带的 `python3` 往往是 3.9,`init.sh` 会自动改找
`python3.13 / 3.12 / 3.11 / 3.10`;最省事的做法是先装
[uv](https://docs.astral.sh/uv/)(脚本会在 `PATH`、`~/.local/bin`、`~/.cargo/bin` 里找它,
并用它拉一个 CPython 3.12),或 `brew install python@3.12`。
- Node.js 20.19+ / 22.12+(Vite 8 的要求;本机 `node -v` 低于 20 时脚本会给出提示)。
```bash ```bash
./scripts/init.sh # 创建虚拟环境、安装依赖、写入种子数据 ./scripts/init.sh # 创建虚拟环境、安装依赖、写入种子数据
@ -61,6 +93,13 @@ nex_math/
打开 http://localhost:5173。 打开 http://localhost:5173。
- `init.sh` 可重复执行:虚拟环境已存在就复用,已有 `data/math.db` 就不重写种子数据。
- 依赖装不上时会自动改用国内镜像重试(pip → 清华源,npm → npmmirror);
也可以显式指定:`PIP_INDEX_URL=… NPM_REGISTRY=… ./scripts/init.sh`;
只想准备后端环境用 `./scripts/init.sh --no-web`。
- `start.sh` 启动前会检查 8000 / 5173 是否被占用并直接告诉你是哪个进程,
换端口:`BACKEND_PORT=8010 FRONTEND_PORT=5180 ./scripts/start.sh`。
默认账号: 默认账号:
| 账号 | 密码 | 角色 | 说明 | | 账号 | 密码 | 角色 | 说明 |
@ -94,15 +133,15 @@ docker compose up --build
| --- | --- | --- | | --- | --- | --- |
| 学习总览 | `/` 学习总览 | 指标卡 + 掌握度 + 推荐计划 | | 学习总览 | `/` 学习总览 | 指标卡 + 掌握度 + 推荐计划 |
| 知识地图 | 知识地图 | 知识点掌握度;点击卡片查看定义、掌握度、相关知识点,以及讲了它的教材章节与视频课程(可一键进入本章测试) | | 知识地图 | 知识地图 | 知识点掌握度;点击卡片查看定义、掌握度、相关知识点,以及讲了它的教材章节与视频课程(可一键进入本章测试) |
| 章节测试 | 章节测试 | 选书→选章→按掌握度动态抽取 5 题,可“换一套”;唯一的组卷与练习入口 | | 章节测试 | 章节测试 | 选书→选章→按掌握度动态抽取 5 题,可“换一套”;唯一的组卷与练习入口。试卷 + 右侧答题卡分屏,需要教材已有章节目录 |
| 错题本 | 错题本 | 记录每道错题的题干、你的错误回答、正确答案与解析 | | 错题本 | 错题本 | 左清单 + 右错题精讲分屏:逐题对照我的作答、正确答案与解析,可按知识点筛选 |
| 练习记录 | 练习记录 | 展开记录可回看每题作答与详细解析 | | 练习记录 | 练习记录 | 左清单 + 右整卷回看:答题卡跳题、只看错题、展开全部解析 |
| 今日任务 | 今日任务 | 推荐今日章节 → 做章节测试 → 对章节提交掌握反馈 | | 今日任务 | 今日任务 | 推荐今日章节 → 做章节测试 → 对章节提交掌握反馈 |
| 学习分析 | 学习分析 | 正确率、错题、诊断排序 | | 学习分析 | 学习分析 | 正确率、错题、诊断排序 |
| 导出 / 导入档案 | 个人设置 → 学习档案 | `/api/archive/export`、`/api/archive/import` | | 导出 / 导入档案 | 个人设置 → 学习档案 | `/api/archive/export`、`/api/archive/import` |
| 教材 / 课程 | 教材 / 课程 | 盖尔范德丛书书目(ISBN)+ 在线课程链接 | | 教材 / 课程 | 教材 / 课程 | 盖尔范德丛书书目(ISBN)+ 在线课程链接 |
| 教材详情 | 点击教材卡片 | 查看章节目录、每章知识点、题目数量并直接开始本章测试 | | 教材详情 | 点击教材卡片 | 查看章节目录、每章知识点、题目数量并直接开始本章测试 |
| 题库管理 | 题库管理 | 列出/筛选/删除题目,按教材章节调用模型生成 | | 题库管理 | 题库管理 | 左章节树 + 筛选、右题目预览卷的分屏;编辑删除题目,按教材章节调用模型生成 |
| 模型配置 | 模型配置 | 多通道模型配置、通道命名、默认通道与连通测试 | | 模型配置 | 模型配置 | 多通道模型配置、通道命名、默认通道与连通测试 |
| 用户管理 | 用户管理(管理员) | 创建用户、启停账号、切换角色、重置密码 | | 用户管理 | 用户管理(管理员) | 创建用户、启停账号、切换角色、重置密码 |
| 个人设置 | 左侧栏底部用户卡片 → 个人设置(主内容区整页) | 修改昵称/密码,导出与导入学习档案 | | 个人设置 | 左侧栏底部用户卡片 → 个人设置(主内容区整页) | 修改昵称/密码,导出与导入学习档案 |
@ -135,15 +174,35 @@ docker compose up --build
| 分册 | ISBN | | 分册 | ISBN |
| --- | --- | | --- | --- |
| 函数和图像(主教材,已挂章节与题库) | 978-7-312-05005-3 | | 函数和图像(主教材,已上传 PDF 电子书) | 978-7-312-05005-3 |
| 代数 | 978-7-312-04893-7 | | 代数 | 978-7-312-04893-7 |
| 三角函数 | 978-7-312-04695-7 | | 三角函数(已上传 PDF) | 978-7-312-04695-7 |
| 坐标方法 | 978-7-312-05006-0 | | 坐标方法 | 978-7-312-05006-0 |
| 几何 | 978-7-312-05779-3 | | 几何 | 978-7-312-05779-3 |
《函数和图像》的章节(引言、第1章 例子 … 第7章 有理函数)已写入数据库, **有正式目录的书,章节由种子预置**:`seed/catalog.py` 里 `OFFICIAL_*_CHAPTERS`
可据此按章节生成新题;其余分册目录可在 `backend/seed/catalog.py` 中扩充后重启生效 是核对过原书的正式目录,按 ISBN 绑到 `CATALOG[*].chapters`,`python -m seed`
(幂等同步,不清空学习记录)。 与后端启动都会幂等地补齐章节,并按 `KNOWLEDGE_CHAPTER_MAP` /
`DEFAULT_CHAPTER_KNOWLEDGE` 把演示题与知识点挂上去(只补空,不覆盖人工改动)。
目前已落地:函数和图像 8 章、三角函数 10 章、坐标方法 7 章、几何 4 章。
**没有正式目录的书不由种子编造**(代数、普林斯顿微积分读本、柯西-施瓦茨大师课)。
管理员在「教材 / 课程」打开某本教材的章节面板后,有三种方式:
1. 「添加章节」手动逐条录入名称与内容提示;
2. 「AI 整理章节」:把书的目录原文(版权页/目录页文字,或在别处查到的正式目录)粘进
「目录参考数据」,任务在后台解析目录并为每章挂上知识图谱节点;模型只做整理,
不新增、不补造素材里没有的章节;
3. 纯接口方式 `POST /api/textbooks/{id}/chapters/generate`:先按 ISBN 查 Open Library /
Google Books 的正式目录,再让模型按正式目录还原,查不到且模型没把握时直接拒绝写入。
确认无误的正式目录也可以回填到 `backend/seed/catalog.py` 的
`OFFICIAL_*_CHAPTERS` / `CATALOG[*].chapters`,再执行
`cd backend && .venv/bin/python -m seed`(幂等:只补缺,不覆盖界面上的修改,也不清空学习记录),
这样换机器或库丢了都能一并重建。
> 种子题库的 18 道演示题会按知识点自动挂到《函数和图像》的章节下(当前:引言 3 题、
> 第6章 幂函数 15 题),所以「章节测试」开箱就能组卷;管理员手工改过的章节归属不会被覆盖。
## 模型配置与题目生成 ## 模型配置与题目生成
@ -163,17 +222,17 @@ docker compose up --build
`![图注](图片地址)`;题库、练习、错题本与练习记录中的题目内容均由前端 KaTeX `![图注](图片地址)`;题库、练习、错题本与练习记录中的题目内容均由前端 KaTeX
渲染公式并展示图片。 渲染公式并展示图片。
### 教材章节的 AI 生成与题库跳转 ### 教材章节目录的整理与题库跳转
在“教材课程管理”中打开某本教材的“章节”抽屉后: 在「教材 / 课程」中打开某本教材的章节面板后:
1. “添加章节”仍可手动输入名称与内容提示; 1. 「添加章节」逐个录入名称与内容提示;
2. “AI 生成章节”按钮位于“添加章节”之后;无需选择数量,选择模型通道与可选说明后, 2. 面板右上角的「AI 整理章节」:选模型通道,把该书目录原文粘进「目录参考数据」,
系统会先查找内置官方目录(函数与图像、三角函数、坐标方法、几何等正式章节), 点「开始整理」。任务在后台执行并显示进度,模型只整理素材里真实出现的目录条目
再按 ISBN 查询 Open Library / Google Books;查到正式目录时直接按正式章节补齐, (不新增、不凭印象补造),同时为每章从知识图谱挑选知识点;与已有章节同名的条目自动跳过,
不会让模型自由发挥;如果无法核实正式目录,接口会明确拒绝生成并提示手动添加, 入库后仍可编辑或删除;
避免产生与原书无关的章节。新章节入库后可继续编辑或删除; 3. 每个章节行都有「题库」入口,点击后跳转到题库管理并自动筛选该书与章节,
3. 每个章节行都有“题库”入口,点击后跳转到题库管理并自动筛选该书与章节。 可以在那里直接按章节生成新题,或把已有的未挂章节题目编辑到该章下。
题库管理每道题会标注“使用状态”(已使用次数 / 未使用)。使用过的题目和被 题库管理每道题会标注“使用状态”(已使用次数 / 未使用)。使用过的题目和被
章节挂载的题目不允许删除,只能编辑;题目编辑会保留历史答题快照。 章节挂载的题目不允许删除,只能编辑;题目编辑会保留历史答题快照。
@ -214,6 +273,7 @@ docker compose up --build
| POST/PUT/DELETE | `/textbooks` 与 `/textbooks/{id}` | 教材/课程增删改(管理员) | | POST/PUT/DELETE | `/textbooks` 与 `/textbooks/{id}` | 教材/课程增删改(管理员) |
| POST/PUT/DELETE | `/textbooks/.../chapters` | 章节管理(管理员) | | POST/PUT/DELETE | `/textbooks/.../chapters` | 章节管理(管理员) |
| POST | `/textbooks/{id}/chapters/generate` | 按教材 AI 生成章节(管理员) | | POST | `/textbooks/{id}/chapters/generate` | 按教材 AI 生成章节(管理员) |
| GET | `/question-bank/summary` | 题库侧栏计数:总数 / 未挂章节 / AI 生成 / 已使用(管理员) |
| GET/POST/PUT/DELETE | `/question-bank/questions`、`…/generate` | 题库管理:列表/生成/编辑/删除(管理员),已使用题禁止删除 | | GET/POST/PUT/DELETE | `/question-bank/questions`、`…/generate` | 题库管理:列表/生成/编辑/删除(管理员),已使用题禁止删除 |
| GET | `/llm/settings` | 模型通道列表与服务商预设(管理员) | | GET | `/llm/settings` | 模型通道列表与服务商预设(管理员) |
| POST/PUT/DELETE | `/llm/channels` 与 `/llm/channels/{id}` | 模型通道增删改(管理员) | | POST/PUT/DELETE | `/llm/channels` 与 `/llm/channels/{id}` | 模型通道增删改(管理员) |
@ -245,3 +305,30 @@ cd backend
会清空全部用户的学习数据与题库(含 AI 生成题)并重新写入种子数据; 会清空全部用户的学习数据与题库(含 AI 生成题)并重新写入种子数据;
用户、角色、教材目录与模型配置保留。演示账号的学习历史(练习记录、错题本) 用户、角色、教材目录与模型配置保留。演示账号的学习历史(练习记录、错题本)
现在包含逐题明细,可直接展开查看每题作答。 现在包含逐题明细,可直接展开查看每题作答。
`--force` 执行前会自动把当前库备份到 `data/backups/`(打印路径),误操作可用
`./scripts/db.sh restore` 回退。
## 数据备份与恢复
`data/*.db` 在 `.gitignore` 里,仓库只有代码和电子书,**数据库文件从来没有进过远端**:
库一旦被重置,章节、题目这些内容就跟着没了。为此有两层保险:
```bash
./scripts/db.sh path # 数据库文件位置
./scripts/db.sh backup 升级前 # 时间戳备份(用 SQLite backup API,服务在写也安全)
./scripts/db.sh list # 本机备份列表(默认保留最近 20 份)
./scripts/db.sh restore [文件|latest] # 回滚;覆盖前先把当前库再备份一份
./scripts/db.sh dump # 导出 data/dump/catalog.sql(内容快照,建议随改动提交)
./scripts/db.sh load # 把 data/dump/catalog.sql 灌回当前库
```
* `dump` 只导**内容**:教材、章节、知识点与关联、课程、题库。
这份文本提交进 git,远端仓库里就始终有一份可恢复的数据库快照。
* 账号、密码散列、答题记录、错题本属于个人数据,**不进 dump、不进 git**;
需要整机迁移时用 `./scripts/db.sh dump --full`(写到 `data/backups/math.full.sql`)
或直接用 `backup` 的二进制备份。
* 新机器上的恢复顺序:`./scripts/init.sh`(建库 + 种子)→ `./scripts/db.sh load` → 重启后端。
* 电子书与库分离:`data/ebooks/` 随仓库提交,库只存文件名。
若库丢了,`seed/ebooks.py` 会按「大小 + SHA-256」认领磁盘上的已知电子书
(`KNOWN_EBOOKS`),重建教材条目并把文件挪回该书目录,不必重新上传。

290
backend/dbtool.py 100644
View File

@ -0,0 +1,290 @@
"""数据库备份 / 恢复 / 快照工具。
``data/*.db`` 在 .gitignore 里,仓库只带代码与电子书,不带数据库文件:
库一旦重置,章节、题目这些"内容"就跟着一起没了。这个工具补上两件事:
* **backup / restore**:本机时间戳备份,seed --force 前自动执行;
* **dump / load**:把目录类内容(教材、章节、知识点、课程、题库)导成
可直接提交的 SQL 文本,放进 git 就等于远端存了一份数据库快照,
而且不含账号、答题记录等个人数据。
用法见 scripts/db.sh(backend/ 目录下也可以 python -m dbtool)。
"""
from __future__ import annotations
import argparse
import sqlite3
from datetime import datetime
from pathlib import Path
from database import DATABASE_URL, DEFAULT_DB_PATH, REPO_ROOT
BACKUP_DIR = REPO_ROOT / "data" / "backups"
DUMP_DIR = REPO_ROOT / "data" / "dump"
DEFAULT_DUMP = DUMP_DIR / "catalog.sql"
# 目录类内容:可以公开提交的部分(不含用户、答题记录、错题本)
CATALOG_TABLES = [
"textbooks",
"chapters",
"knowledge",
"chapter_knowledge",
"courses",
"knowledge_relations",
"knowledge_resources",
"questions",
"question_knowledge",
]
# 个人学习数据:只进本机备份,不进 git
PRIVATE_TABLES = [
"users",
"roles",
"permissions",
"role_permissions",
"user_roles",
"user_knowledge",
"questions",
"question_knowledge",
"attempt_sessions",
"attempt_items",
"error_entries",
"daily_completions",
"ebook_progress",
"llm_settings",
]
def db_path() -> Path:
"""当前 SQLite 文件路径(非 sqlite 后端时退回默认路径)。"""
prefix = "sqlite:///"
if DATABASE_URL.startswith(prefix):
return Path(DATABASE_URL[len(prefix):])
return DEFAULT_DB_PATH
def backup(label: str = "", keep: int = 20) -> Path:
"""在线备份当前库(用 SQLite backup API,服务正在写也安全)。"""
source = db_path()
if not source.is_file():
raise FileNotFoundError(f"数据库不存在:{source}")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = f"-{_slug(label)}" if label else ""
target = BACKUP_DIR / f"{source.stem}-{stamp}{suffix}.db"
src = sqlite3.connect(source)
try:
dst = sqlite3.connect(target)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
prune(keep)
return target
def restore(source_file: Path, protect: bool = True) -> Path | None:
"""用备份覆盖当前库;覆盖前先把当前库存一份。"""
if not source_file.is_file():
raise FileNotFoundError(f"备份不存在:{source_file}")
saved = backup(label="pre-restore") if protect else None
src = sqlite3.connect(source_file)
try:
dst = sqlite3.connect(db_path())
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
return saved
def prune(keep: int = 20) -> None:
"""只保留最近 keep 份备份。"""
if keep <= 0 or not BACKUP_DIR.is_dir():
return
files = sorted(BACKUP_DIR.glob("*.db"), key=lambda p: p.stat().st_mtime)
for old in files[:-keep]:
old.unlink(missing_ok=True)
def list_backups() -> list[Path]:
if not BACKUP_DIR.is_dir():
return []
return sorted(BACKUP_DIR.glob("*.db"), key=lambda p: p.name, reverse=True)
def _literal(value: object) -> str:
if value is None:
return "NULL"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, (bytes, bytearray)):
return f"X'{bytes(value).hex()}'"
return "'" + str(value).replace("'", "''") + "'"
def dump_sql(
out: Path = DEFAULT_DUMP, tables: list[str] | None = None
) -> tuple[Path, int]:
"""把内容表导成 SQL 文本(先清表再按原 id 插回,可重复执行)。"""
wanted = tables or CATALOG_TABLES
conn = sqlite3.connect(db_path())
conn.row_factory = sqlite3.Row
try:
existing = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
lines = [
"-- nex_math 内容快照(教材 / 章节 / 知识点 / 课程 / 题库)",
f"-- 生成时间:{datetime.now().isoformat(timespec='seconds')}",
"-- 恢复:./scripts/db.sh load data/dump/catalog.sql",
"PRAGMA foreign_keys=OFF;",
"BEGIN TRANSACTION;",
]
rows_total = 0
for table in reversed(wanted):
if table in existing:
lines.append(f"DELETE FROM {table};")
for table in wanted:
if table not in existing:
continue
count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
if not count:
continue
lines.append(f"-- {table}: {count} 行")
for row in conn.execute(f"SELECT * FROM {table}"):
columns = row.keys()
values = ", ".join(_literal(row[key]) for key in columns)
lines.append(
f"INSERT INTO {table} ({', '.join(columns)}) "
f"VALUES ({values});"
)
rows_total += 1
lines += ["COMMIT;", "VACUUM;"]
finally:
conn.close()
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
return out, rows_total
def _has_tables(conn: sqlite3.Connection) -> bool:
return (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1"
).fetchone()
is not None
)
def load_sql(script: Path) -> bool:
"""把 dump 出来的 SQL 文本灌回当前库(内容表按快照里的状态覆盖)。
库文件不存在或还没有表时,先按 ORM 模型建好表结构再灌数据,
这样新克隆的仓库也能一条命令导入远端快照。返回值表示本次是否新建了库。
"""
if not script.is_file():
raise FileNotFoundError(f"快照不存在:{script}")
target = db_path()
target.parent.mkdir(parents=True, exist_ok=True)
created = False
conn = sqlite3.connect(target)
try:
if not _has_tables(conn):
conn.close()
# models 必须先导入,Base.metadata 才知道要建哪些表
import models # noqa: F401
from database import ensure_schema # 建表(含旧库补列)
ensure_schema()
created = True
conn = sqlite3.connect(target)
conn.executescript(script.read_text(encoding="utf-8"))
conn.commit()
finally:
conn.close()
return created
def _slug(text: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in text)
return cleaned.strip("-")[:32] or "backup"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="nex_math 数据库备份工具")
sub = parser.add_subparsers(dest="cmd", required=True)
p_backup = sub.add_parser("backup", help="备份当前库")
p_backup.add_argument("label", nargs="?", default="", help="备份标签")
p_restore = sub.add_parser("restore", help="用备份覆盖当前库")
p_restore.add_argument("file", nargs="?", default="latest", help="备份文件")
sub.add_parser("list", help="列出本机备份")
p_dump = sub.add_parser("dump", help="导出可提交的内容快照")
p_dump.add_argument("out", nargs="?", default=str(DEFAULT_DUMP))
p_dump.add_argument("--full", action="store_true", help="含用户与学习数据")
p_load = sub.add_parser("load", help="把内容快照灌回当前库")
p_load.add_argument("file", nargs="?", default=str(DEFAULT_DUMP))
sub.add_parser("path", help="打印数据库文件路径")
args = parser.parse_args(argv)
if args.cmd == "path":
print(db_path())
elif args.cmd == "backup":
print(backup(label=args.label))
elif args.cmd == "list":
items = list_backups()
if not items:
print(f"({BACKUP_DIR} 下暂无备份)")
for item in items:
print(f"{item} {item.stat().st_size // 1024} KB")
elif args.cmd == "restore":
target = (
list_backups()[0]
if args.file == "latest"
else Path(args.file)
)
if not target.is_absolute():
target = (BACKUP_DIR / args.file).resolve()
saved = restore(target)
print(f"已恢复:{target}")
if saved:
print(f"覆盖前的库已备份到:{saved}")
print("提示:重启后端后生效。")
elif args.cmd == "dump":
if args.full:
# 含账号与学习数据:只写进本机备份目录,不进 git
tables = CATALOG_TABLES + [
t for t in PRIVATE_TABLES if t not in CATALOG_TABLES
]
out = BACKUP_DIR / "math.full.sql"
else:
tables = CATALOG_TABLES
out = Path(args.out)
written, rows = dump_sql(out, tables)
print(f"{written} {rows} 行")
elif args.cmd == "load":
created = load_sql(Path(args.file))
print(f"已导入:{args.file}")
if created:
print("提示:这是一个新建的库。快照只含内容(教材/章节/知识点/题目),"
"账号与学习数据请用 ./scripts/init.sh 或 python -m seed 生成。")
print("提示:重启后端后生效。")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -21,6 +21,7 @@ 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.rbac import DEFAULT_USERS, ensure_rbac from seed.rbac import DEFAULT_USERS, ensure_rbac
from services.learning_profile import ( from services.learning_profile import (
ensure_default_profiles, ensure_default_profiles,
@ -129,6 +130,11 @@ def load_seed(force: bool = False) -> bool:
ensure_llm_channels(db) ensure_llm_channels(db)
if force: if force:
# --force 会清掉题库与学习数据:先留一份可回退的备份
from dbtool import backup as backup_database
saved = backup_database(label="pre-force")
print(f"[seed] --force 将清空题库与学习数据,已先备份:{saved}")
db.query(AttemptItem).delete() db.query(AttemptItem).delete()
db.query(DailyCompletion).delete() db.query(DailyCompletion).delete()
db.query(ErrorEntry).delete() db.query(ErrorEntry).delete()
@ -156,6 +162,8 @@ def load_seed(force: bool = False) -> bool:
db.commit() db.commit()
ensure_catalog(db, force_sync=force) ensure_catalog(db, force_sync=force)
# 电子书与教材目录同源:种子跑完就把 res/ 的书和磁盘上的存量电子书挂好
ensure_ebooks(db)
for user in users.values(): for user in users.values():
if user and user_has_admin_role(db, user.id): if user and user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id) remove_unused_admin_profile(db, user.id)

View File

@ -171,7 +171,13 @@ COURSES = [
}, },
] ]
OFFICIAL_CHAPTERS_BY_ISBN = {} # 官方章节模板 → 教材:按 ISBN 绑定,seed 在空库或补齐时据此写入章节目录。
OFFICIAL_CHAPTERS_BY_ISBN = {
"9787312050053": OFFICIAL_PRIMARY_CHAPTERS, # 函数和图像
"9787312057793": OFFICIAL_GEOMETRY_CHAPTERS, # 几何
"9787312046957": OFFICIAL_TRIG_CHAPTERS, # 三角函数
"9787312050060": OFFICIAL_COORDINATE_CHAPTERS, # 坐标方法
}
CATALOG = [ CATALOG = [
@ -189,7 +195,7 @@ CATALOG = [
"英译本 Functions and Graphs 由 Dover 出版。现有函数与对称性题库" "英译本 Functions and Graphs 由 Dover 出版。现有函数与对称性题库"
"以本书为依据。" "以本书为依据。"
), ),
"chapters": [], "chapters": OFFICIAL_PRIMARY_CHAPTERS,
}, },
{ {
"name": "代数(盖尔范德中学生数学思维丛书)", "name": "代数(盖尔范德中学生数学思维丛书)",
@ -217,7 +223,7 @@ CATALOG = [
"以直观可视的方式从图形构造逐步建立几何,突出“先看见、再证明”" "以直观可视的方式从图形构造逐步建立几何,突出“先看见、再证明”"
"的思维路线。" "的思维路线。"
), ),
"chapters": [], "chapters": OFFICIAL_GEOMETRY_CHAPTERS,
}, },
{ {
"name": "三角函数(盖尔范德中学生数学思维丛书)", "name": "三角函数(盖尔范德中学生数学思维丛书)",
@ -230,7 +236,7 @@ CATALOG = [
"description": ( "description": (
"从初等几何直觉进入正弦、余弦与正切,解释周期与波动现象的数学本质。" "从初等几何直觉进入正弦、余弦与正切,解释周期与波动现象的数学本质。"
), ),
"chapters": [], "chapters": OFFICIAL_TRIG_CHAPTERS,
}, },
{ {
"name": "坐标方法(盖尔范德中学生数学思维丛书)", "name": "坐标方法(盖尔范德中学生数学思维丛书)",
@ -244,98 +250,102 @@ CATALOG = [
"讲解如何用坐标把几何问题转化为代数问题,是解析几何与函数图像" "讲解如何用坐标把几何问题转化为代数问题,是解析几何与函数图像"
"方法的基础。" "方法的基础。"
), ),
"chapters": [], "chapters": OFFICIAL_COORDINATE_CHAPTERS,
}, },
] ]
def ensure_catalog(db: Session, force_sync: bool = False) -> None: def ensure_catalog(db: Session, force_sync: bool = False) -> None:
"""仅在空库时写入默认目录;管理员后续可自由增删改并持久保留。""" """写入默认教材目录。
空库(或 ``--force``)时按下述顺序全量登记教材与官方章节;存量库只
*补齐缺失的章节*,既不覆盖管理员改过的教材信息,也不改动已有章节顺序,
因此可以随每次启动安全重放。
"""
_ensure_courses(db) _ensure_courses(db)
if db.query(Textbook).count() > 0 and not force_sync: if db.query(Textbook).count() == 0 or force_sync:
# 存量库不覆盖用户修改,但会补齐新增的“官方正式目录”章节 for position, item in enumerate(CATALOG):
textbook = _upsert_textbook(db, item, position)
_backfill_chapters(db, textbook, item["chapters"])
else:
for item in CATALOG: for item in CATALOG:
textbook = ( textbook = (
db.query(Textbook) db.query(Textbook).filter(Textbook.name == item["name"]).first()
.filter(Textbook.name == item["name"])
.first()
) )
if textbook is None or not item["chapters"]: if textbook is None:
continue continue
existing = { _backfill_chapters(db, textbook, item["chapters"])
chapter.name: chapter db.flush()
for chapter in db.query(Chapter) _link_unassigned_questions(db)
.filter(Chapter.textbook_id == textbook.id)
.all()
}
position = len(existing)
for chapter in item["chapters"]:
if chapter["name"] not in existing:
db.add(
Chapter(
textbook_id=textbook.id,
name=chapter["name"],
position=position,
summary=chapter["summary"],
)
)
position += 1
_sync_default_chapter_knowledge(db)
_sync_default_course_resources(db)
db.commit()
return
for position, item in enumerate(CATALOG):
textbook = db.query(Textbook).filter(Textbook.name == item["name"]).first()
if textbook is None:
textbook = Textbook(name=item["name"], position=position)
db.add(textbook)
db.flush()
textbook.author = item["author"]
textbook.publisher = item["publisher"]
textbook.isbn = item["isbn"]
textbook.edition = item["edition"]
textbook.grade = item["grade"]
textbook.link = item["link"]
textbook.description = item["description"]
textbook.position = position
existing = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.all()
}
for chapter_index, chapter in enumerate(item["chapters"]):
if chapter["name"] not in existing:
db.add(
Chapter(
textbook_id=textbook.id,
name=chapter["name"],
position=chapter_index,
summary=chapter["summary"],
)
)
# 按最新知识点-章节映射重新关联所有题目
primary = db.query(Textbook).filter(Textbook.name == CATALOG[0]["name"]).first()
if primary:
chapter_map = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == primary.id)
.all()
}
for question in db.query(Question).all():
chapter = chapter_map.get(
KNOWLEDGE_CHAPTER_MAP.get(question.knowledge_name, "")
)
question.chapter_id = chapter.id if chapter else None
_sync_default_chapter_knowledge(db) _sync_default_chapter_knowledge(db)
_sync_default_course_resources(db) _sync_default_course_resources(db)
db.commit() db.commit()
def _upsert_textbook(db: Session, item: dict, position: int) -> Textbook:
textbook = db.query(Textbook).filter(Textbook.name == item["name"]).first()
if textbook is None:
textbook = Textbook(name=item["name"], position=position)
db.add(textbook)
db.flush()
textbook.author = item["author"]
textbook.publisher = item["publisher"]
textbook.isbn = item["isbn"]
textbook.edition = item["edition"]
textbook.grade = item["grade"]
textbook.link = item["link"]
textbook.description = item["description"]
textbook.position = position
return textbook
def _backfill_chapters(db: Session, textbook: Textbook, chapters: list[dict]) -> None:
"""按官方目录补齐缺失章节(已有的同名章节原样保留)。"""
if not chapters:
return
existing = {
chapter.name
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.all()
}
position = len(existing)
for chapter in chapters:
if chapter["name"] in existing:
continue
db.add(
Chapter(
textbook_id=textbook.id,
name=chapter["name"],
position=position,
summary=chapter["summary"],
)
)
position += 1
def _link_unassigned_questions(db: Session) -> None:
"""把尚未归入章节的题目按知识点-章节映射挂到主教材对应章节。
只处理 ``chapter_id`` 为空的题目:管理员手工挂过或改过的题目不动,
重放种子脚本不会冲掉人工维护结果。
"""
primary = db.query(Textbook).filter(Textbook.name == CATALOG[0]["name"]).first()
if primary is None:
return
chapter_map = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == primary.id)
.all()
}
pending = db.query(Question).filter(Question.chapter_id.is_(None)).all()
for question in pending:
chapter = chapter_map.get(KNOWLEDGE_CHAPTER_MAP.get(question.knowledge_name, ""))
if chapter is not None:
question.chapter_id = chapter.id
def _sync_default_chapter_knowledge(db: Session) -> None: def _sync_default_chapter_knowledge(db: Session) -> None:
"""仅为还没有知识点关联的章节写入默认知识点(不覆盖管理员维护)。""" """仅为还没有知识点关联的章节写入默认知识点(不覆盖管理员维护)。"""
for chapter in db.query(Chapter).all(): for chapter in db.query(Chapter).all():

View File

@ -4,6 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import shutil
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -47,6 +48,71 @@ def _find_resource(prefix: str, suffix: str) -> Path | None:
return None return None
# 旧库里的教材:数据库只是引用磁盘上的文件,库一旦丢失,这些文件就成了孤儿。
# 按“大小 + SHA-256”认领已知电子书,重建教材条目并把文件挪进该书自己的目录,
# 管理员就不必重新上传,种子也不会再往 res/ 里复制第二份。
KNOWN_EBOOKS = [
{
"size": 44110741,
"sha256": "f432fa058420f1032a169a91fe2dcefbc95c61a6793f1b0ec5fd1fcc84ee67da",
"display": "柯西-施瓦茨大师课:不等式的艺术.pdf",
"textbook": {
"name": "柯西-施瓦茨大师课:不等式的艺术",
"author": "J. Michael Steele 著;欧阳顺湘 译",
"publisher": "高等教育出版社 · 世界数学精品译丛 7",
"isbn": "9787040278678",
"grade": "高中至大学",
"description": (
"Cambridge 出版的不等式专题大师课:以 Cauchy-Schwarz 不等式为主线,"
"串起凸性、排序、切比雪夫、Young、Hölder、Minkowski 等常用不等式,"
"每章配大量例题与习题,适合竞赛与数学分析进阶训练。"
),
},
},
]
def _referenced_files(db: Session) -> set[Path]:
used: set[Path] = set()
rows = db.query(Textbook).filter(Textbook.ebook_file != "").all()
for row in rows:
used.add((store.book_dir(row.id) / row.ebook_file).resolve())
return used
def recover_lost_ebooks(db: Session) -> list[str]:
"""认领 data/ebooks 下未被引用的已知电子书,返回恢复的教材名。"""
if not store.EBOOK_ROOT.is_dir():
return []
referenced = _referenced_files(db)
recovered: list[str] = []
for book in KNOWN_EBOOKS:
meta = book["textbook"]
if db.query(Textbook).filter(Textbook.name == meta["name"]).first():
continue
for path in sorted(store.EBOOK_ROOT.glob("*/*")):
if not path.is_file() or path.suffix.lower() not in {".pdf", ".epub"}:
continue
if path.stat().st_size != book["size"] or path.resolve() in referenced:
continue
if store.sha256_file(path) != book["sha256"]:
continue
textbook = Textbook(
**meta, position=db.query(Textbook).count()
)
db.add(textbook)
db.commit()
target_dir = store.book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(path), str(target_dir / path.name))
store.adopt_file(
db, textbook, path.name, display_name=book["display"]
)
recovered.append(textbook.name)
break
return recovered
def ensure_ebooks(db: Session) -> None: def ensure_ebooks(db: Session) -> None:
created = False created = False
textbook = ( textbook = (
@ -86,3 +152,5 @@ def ensure_ebooks(db: Session) -> None:
if pdf is None: if pdf is None:
continue continue
store.attach_local_file(db, textbook, pdf, display_name=display) store.attach_local_file(db, textbook, pdf, display_name=display)
recover_lost_ebooks(db)

View File

@ -5,6 +5,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
import re import re
import shutil import shutil
@ -133,6 +134,48 @@ def clear_ebook(db: Session, textbook: Textbook) -> Textbook:
return textbook return textbook
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as reader:
for chunk in iter(lambda: reader.read(_CHUNK), b""):
digest.update(chunk)
return digest.hexdigest()
def find_identical(textbook_id: int, source: Path) -> Path | None:
"""该书目录下是否已经有一模一样的文件(按大小 + SHA-256 判定)。"""
target_dir = book_dir(textbook_id)
if not target_dir.is_dir() or not source.is_file():
return None
size = source.stat().st_size
digest = sha256_file(source)
for candidate in sorted(target_dir.iterdir()):
if not candidate.is_file() or candidate.stat().st_size != size:
continue
if sha256_file(candidate) == digest:
return candidate
return None
def adopt_file(
db: Session,
textbook: Textbook,
filename: str,
display_name: str = "",
) -> Textbook:
"""登记该书目录下已经在磁盘上的文件(认领孤儿电子书,不重新复制)。"""
path = resolve_path(textbook.id, filename)
fmt, _ = file_kind(path.name)
textbook.ebook_file = path.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(display_name or path.name)
textbook.ebook_size = path.stat().st_size
textbook.ebook_pages = count_pages(path, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def attach_local_file( def attach_local_file(
db: Session, db: Session,
textbook: Textbook, textbook: Textbook,
@ -145,6 +188,10 @@ def attach_local_file(
raise FileNotFoundError(str(source)) raise FileNotFoundError(str(source))
target_dir = book_dir(textbook.id) target_dir = book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
# 同一本书重复导入(例如换库重跑种子)时复用已有文件,避免多占几十 MB
identical = find_identical(textbook.id, source)
if identical is not None:
return adopt_file(db, textbook, identical.name, display_name=display_name)
final = target_dir / f"{uuid.uuid4().hex}.{fmt}" final = target_dir / f"{uuid.uuid4().hex}.{fmt}"
with source.open("rb") as reader, final.open("wb") as writer: with source.open("rb") as reader, final.open("wb") as writer:
head = reader.read(len(magic)) head = reader.read(len(magic))

View File

@ -0,0 +1,212 @@
-- nex_math 内容快照(教材 / 章节 / 知识点 / 课程 / 题库)
-- 生成时间:2026-09-18T14:52:08
-- 恢复:./scripts/db.sh load data/dump/catalog.sql
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
DELETE FROM question_knowledge;
DELETE FROM questions;
DELETE FROM knowledge_resources;
DELETE FROM knowledge_relations;
DELETE FROM courses;
DELETE FROM chapter_knowledge;
DELETE FROM knowledge;
DELETE FROM chapters;
DELETE FROM textbooks;
-- textbooks: 7 行
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (1, '函数和图像(盖尔范德中学生数学思维丛书)', 'I. M. Gelfand, E. G. Glagoleva, E. E. Shnol 著;麻希南、胡长青 译', '中国科学技术大学出版社', '9787312050053', '2020年12月第1版', '初中至高中低年级', '', '本系统的主教材。以大量真实图像(地震图、心电图等)引入函数,强调“画图就是看见公式”,系统训练函数概念、图像与性质直觉;英译本 Functions and Graphs 由 Dover 出版。现有函数与对称性题库以本书为依据。', 0, 'da4d4ff2d0b0448fac3719cb7d536d5b.pdf', '函数和图像(盖尔范德中学生数学思维丛书).pdf', 'pdf', 2352775, 108, '2026-09-18 05:52:20.133460');
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (2, '代数(盖尔范德中学生数学思维丛书)', 'I. M. Gelfand, A. Shen 著;胡长青、麻希南 译', '中国科学技术大学出版社', '9787312048937', '2020年第1版', '初中至高中低年级', '', '用问题驱动的叙述串联自然数、负数、代数式与方程,重视“为什么可以这样变形”的代数思维,适合作为代数学基础。', 1, '', '', '', 0, 0, NULL);
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (3, '几何(盖尔范德中学生数学思维丛书)', 'I. M. Gelfand 等 著', '中国科学技术大学出版社', '9787312057793', '2023年第1版', '初中至高中低年级', '', '以直观可视的方式从图形构造逐步建立几何,突出“先看见、再证明”的思维路线。', 2, '', '', '', 0, 0, NULL);
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (4, '三角函数(盖尔范德中学生数学思维丛书)', 'I. M. Gelfand, M. Saul 著;王作勤 译', '中国科学技术大学出版社', '9787312046957', '2020年第1版', '初中至高中低年级', '', '从初等几何直觉进入正弦、余弦与正切,解释周期与波动现象的数学本质。', 3, 'c1945315d608455eb429eacc3378d7d5.pdf', '三角函数(盖尔范德中学生数学思维丛书).pdf', 'pdf', 4804517, 228, '2026-09-18 05:52:20.155476');
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (5, '坐标方法(盖尔范德中学生数学思维丛书)', 'I. M. Gelfand, E. G. Glagoleva, A. A. Kirillov 著;王作勤 译', '中国科学技术大学出版社', '9787312050060', '2020年12月第1版', '初中至高中低年级', '', '讲解如何用坐标把几何问题转化为代数问题,是解析几何与函数图像方法的基础。', 4, '', '', '', 0, 0, NULL);
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (6, '普林斯顿微积分读本(修订版)', '阿德里安·班纳 (Adrian Banner)', '人民邮电出版社', '', '', '高中至大学先修', '', '面向微积分入门的系统辅导书,覆盖极限、导数、积分与级数,按考点组织,适合自学与考前查漏补缺。', 5, '4559e9e2fa084fb7a8e8a06aea5196bc.epub', '普林斯顿微积分读本(修订版).epub', 'epub', 9842024, 190, '2026-09-18 05:52:20.122225');
INSERT INTO textbooks (id, name, author, publisher, isbn, edition, grade, link, description, position, ebook_file, ebook_name, ebook_format, ebook_size, ebook_pages, ebook_uploaded_at) VALUES (7, '柯西-施瓦茨大师课:不等式的艺术', 'J. Michael Steele 著;欧阳顺湘 译', '高等教育出版社 · 世界数学精品译丛 7', '9787040278678', '', '高中至大学', '', 'Cambridge 出版的不等式专题大师课:以 Cauchy-Schwarz 不等式为主线,串起凸性、排序、切比雪夫、Young、Hölder、Minkowski 等常用不等式,每章配大量例题与习题,适合竞赛与数学分析进阶训练。', 6, '8dc7e48036ac4db8abf0020334b47b07.pdf', '柯西-施瓦茨大师课:不等式的艺术.pdf', 'pdf', 44110741, 268, '2026-09-18 06:44:14.147181');
-- chapters: 29 行
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (1, 1, '引言', 0, '函数、自变量、定义域与函数值:从真实图像理解函数。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (2, 1, '第1章 例子', 1, '不同函数图像的直观例子与作图方法。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (3, 1, '第2章 线性函数', 2, '一次函数的图像、斜率与直线。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (4, 1, '第3章 函数 y=|x|', 3, '绝对值函数的图像与性质,关于 y 轴对称的典型例子。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (5, 1, '第4章 二次三项式', 4, '二次函数图像、配方、判别式与零点。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (6, 1, '第5章 分式线性函数', 5, 'y=1/x 类函数图像与渐近行为。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (7, 1, '第6章 幂函数', 6, 'y=x、y=x²、y=x³ 等幂函数的奇偶性与对称性。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (8, 1, '第7章 有理函数', 7, '更一般的有理函数图像与性质综合。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (9, 3, '点与直线:走进射影几何', 0, '从点与直线的位置关系出发理解射影几何的基本思想。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (10, 3, '平行线:走进仿射几何', 1, '以平行关系为线索建立仿射几何的直观框架。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (11, 3, '面积:走进辛几何', 2, '通过面积概念与保持面积变换认识辛几何。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (12, 3, '圆:走进欧氏几何', 3, '以圆为核心回到经典欧氏几何的结构与性质。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (13, 4, '第1章 三角函数', 0, '从角与旋转进入三角函数,建立正弦、余弦、正切的基本概念。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (14, 4, '第2章 三角形中的三角比', 1, '在直角三角形中定义正弦、余弦与正切,理解锐角三角比。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (15, 4, '第3章 三角比之间的关系', 2, '同角三角比的关系、互余关系与基本恒等式推导。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (16, 4, '第4章 三角形内的关系', 3, '正弦定理、余弦定理以及三角形中边角关系的应用。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (17, 4, '第5章 角与旋转', 4, '把角推广到任意旋转角,定义任意角的三角函数。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (18, 4, '第6章 弧度法', 5, '引入弧度制,理解弧长、扇形与角度制换算。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (19, 4, '第7章 加法公式', 6, '两角和与差的三角函数公式及其几何与代数证明。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (20, 4, '第8章 三角恒等式', 7, '倍角、半角与和差化积等常用三角恒等式。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (21, 4, '第9章 三角函数的图形', 8, '正弦、余弦、正切函数的图像、周期与变换。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (22, 4, '第10章 反函数与三角方程', 9, '反三角函数的基本概念与三角方程的求解方法。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (23, 5, '绪论', 0, '用坐标把几何问题转化为代数问题的方法引言。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (24, 5, '第1章 直线上点的坐标', 1, '数轴、数的绝对值与两点之间的距离。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (25, 5, '第2章 平面里点的坐标', 2, '坐标平面、坐标间关系、距离公式、确定平面图形与其他坐标系。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (26, 5, '第3章 三维空间里点的坐标', 3, '空间坐标轴与坐标平面,确定空间中的图形。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (27, 5, '第4章 引言', 4, '从三维推广到四维空间的动机:几何、计算与物理。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (28, 5, '第5章 四维空间', 5, '四维空间中的坐标轴、坐标平面与距离问题。', 0);
INSERT INTO chapters (id, textbook_id, name, position, summary, ebook_page) VALUES (29, 5, '第6章 四维方体', 6, '球与方体的定义、四维方体的结构与相关问题。', 0);
-- knowledge: 48 行
INSERT INTO knowledge (id, name, domain, category, description) VALUES (1, '偶函数', '初等', '函数与图像', '满足 f(-x)=f(x) 的函数,其图像关于 y 轴对称。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (2, '奇函数', '初等', '函数与图像', '满足 f(-x)=-f(x) 的函数,其图像关于原点中心对称;若 0 在定义域内,则 f(0)=0。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (3, '定义域', '初等', '函数与图像', '使函数表达式有意义的所有自变量取值组成的集合,常见限制有分母不为零、偶次根号下非负、对数的真数为正等。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (4, '图像对称性', '初等', '函数与图像', '函数图像的轴对称与中心对称性质,与奇偶性、平移伸缩等变换密切相关。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (5, '初等数学', '初等', '总纲', '中学与竞赛基础的数学知识总图');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (6, '数与式', '初等', '数与式', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (7, '方程与不等式', '初等', '方程与不等式', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (8, '函数与图像', '初等', '函数与图像', '以图像直观呈现自变量与函数值对应关系的分支:先“看见”图像,再回到公式与性质。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (9, '三角函数', '初等', '三角函数', '以角为自变量、以三角比为函数值的函数,包括正弦、余弦、正切等,具有周期性与有界性。');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (10, '平面几何', '初等', '平面几何', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (11, '坐标与解析几何', '初等', '坐标与解析几何', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (12, '数列与证明', '初等', '数列与证明', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (13, '概率统计', '初等', '概率统计', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (14, '高等数学', '高等', '总纲', '大学基础数学知识总图');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (15, '分析与微积分', '高等', '分析与微积分', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (16, '线性代数', '高等', '线性代数', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (17, '概率论与数理统计', '高等', '概率论与数理统计', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (18, '离散与优化', '高等', '离散与优化', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (19, '函数概念', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (20, '函数值', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (21, '正负性', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (22, '零点', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (23, '点线面的基本关系', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (24, '几何变换', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (25, '平行与垂直', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (26, '面积与周长', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (27, '圆', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (28, '三角比', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (29, '直角三角形', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (30, '三角恒等式', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (31, '同角关系', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (32, '正弦定理', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (33, '余弦定理', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (34, '任意角', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (35, '弧度制', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (36, '弧长与扇形', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (37, '和角公式', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (38, '差角公式', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (39, '倍角公式', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (40, '三角函数图像', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (41, '周期函数', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (42, '反三角函数', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (43, '三角方程', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (44, '数轴', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (45, '平面直角坐标系', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (46, '实数', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (47, '两点间距离', '初等', '', '');
INSERT INTO knowledge (id, name, domain, category, description) VALUES (48, '曲线与方程', '初等', '', '');
-- chapter_knowledge: 48 行
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (1, 1, 19);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (2, 1, 20);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (3, 1, 3);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (4, 1, 21);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (5, 5, 22);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (6, 7, 1);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (7, 7, 2);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (8, 7, 4);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (9, 9, 23);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (10, 9, 24);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (11, 10, 25);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (12, 11, 26);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (13, 12, 27);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (14, 13, 9);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (15, 14, 28);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (16, 14, 29);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (17, 15, 30);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (18, 15, 31);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (19, 16, 32);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (20, 16, 33);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (21, 17, 34);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (22, 17, 35);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (23, 18, 35);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (24, 18, 36);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (25, 19, 37);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (26, 19, 38);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (27, 20, 39);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (28, 20, 30);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (29, 21, 40);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (30, 21, 41);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (31, 22, 42);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (32, 22, 43);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (33, 23, 44);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (34, 23, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (35, 24, 44);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (36, 24, 46);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (37, 24, 47);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (38, 25, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (39, 25, 47);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (40, 25, 48);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (41, 26, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (42, 26, 23);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (43, 27, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (44, 27, 48);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (45, 28, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (46, 28, 48);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (47, 29, 45);
INSERT INTO chapter_knowledge (id, chapter_id, knowledge_id) VALUES (48, 29, 48);
-- courses: 3 行
INSERT INTO courses (id, name, provider, url, grade, description, position) VALUES (1, 'Khan Academy · Algebra 1: Functions', 'Khan Academy', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', '初中衔接 / 高中', '函数单元含函数求值、定义域值域、函数图像与性质等大量免费练习,英文界面,适合配合盖尔范德丛书做自动化巩固。', 0);
INSERT INTO courses (id, name, provider, url, grade, description, position) VALUES (2, '3Blue1Brown · Essence of Calculus', '3Blue1Brown', 'https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr', '高中高年级 / 大学先修', '微积分本质系列:从几何直觉理解导数与积分,适合函数图像直觉建立后进入微积分阶段使用。', 1);
INSERT INTO courses (id, name, provider, url, grade, description, position) VALUES (3, '国家中小学智慧教育平台 · 高中数学(人教A版)', '教育部教育技术与资源发展中心', 'https://basic.smartedu.cn/syncClassroom', '高中全学段', '官方免费同步课程与配套课件,可按教材版本与册次点播,适合做课内进度对照。', 2);
-- knowledge_relations: 2 行
INSERT INTO knowledge_relations (id, source_id, target_id, relation_type) VALUES (1, 1, 2, '前置');
INSERT INTO knowledge_relations (id, source_id, target_id, relation_type) VALUES (2, 8, 9, '前置');
-- knowledge_resources: 14 行
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (1, 3, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (2, 1, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (3, 2, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (4, 4, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (5, 8, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (6, 9, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (7, 19, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (8, 20, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (9, 22, 'video', 'Khan Academy · Algebra 1: Functions', 'https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions', NULL, 1, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (10, 34, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (11, 35, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (12, 28, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (13, 30, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
INSERT INTO knowledge_resources (id, knowledge_id, kind, title, url, textbook_id, course_id, position) VALUES (14, 40, 'video', '国家中小学智慧教育平台 · 高中数学(人教A版)', 'https://basic.smartedu.cn/syncClassroom', NULL, 3, 0);
-- questions: 18 行
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (1, 'bank', '已知函数 f(x) 满足 f(-x)=f(x),且 f(3)=5。下列哪个点一定在图像上?', '["A. (-3,5)", "B. (-3,-5)", "C. (3,-5)", "D. (5,-3)"]', 0, '偶函数', '由偶函数定义 f(-x)=f(x) 可知,点 (3,5) 关于 y 轴的镜像点 (-3,5) 一定在图像上。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (2, 'bank', '已知函数 f(x) 满足 f(-x)=-f(x),且 f(2)=4。下列哪个点一定在图像上?', '["A. (-2,4)", "B. (-2,-4)", "C. (2,-4)", "D. (4,-2)"]', 1, '奇函数', '由奇函数定义 f(-x)=-f(x) 可知,(2,4) 关于原点的对称点 (-2,-4) 一定在图像上。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (3, 'bank', '下列函数的图像关于 $y$ 轴对称的是?', '["A. $y=x+1$", "B. $y=x^3$", "C. $y=x^2+2$", "D. $y=x^2+x$"]', 2, '偶函数', '$y=x^2+2$ 满足 $f(-x)=f(x)$,因此图像关于 $y$ 轴对称,属于偶函数。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (4, 'bank', '函数 y=1/(x²-9) 的图像不可能经过哪个点?', '["A. (0,-1/9)", "B. (4,1/7)", "C. (-4,1/7)", "D. (3,1)"]', 3, '定义域', '分母 x²-9 不能为 0,x=±3 不在定义域内,所以图像不可能经过 (3,1)。', 1, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (5, 'bank', '若函数图像关于原点对称,则下列关系一定成立的是?', '["A. f(-x)=f(x)", "B. f(-x)=-f(x)", "C. f(x)=0", "D. f(x)>0"]', 1, '奇函数', '图像关于原点对称对应奇函数,即对任意定义域内的 x 有 f(-x)=-f(x)。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (6, 'bank', '已知 $f(-x)=f(x)$,则函数图像的几何性质是?', '["A. 关于 x 轴对称", "B. 关于 y 轴对称", "C. 关于原点对称", "D. 无法判断"]', 1, '偶函数', 'f(-x)=f(x) 是偶函数的定义式,几何上等价于图像关于 y 轴对称。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (7, 'bank', '函数 y=2/(x²-1) 的图像不可能经过哪个点?', '["A. (0,-2)", "B. (2,2/3)", "C. (-2,2/3)", "D. (1,2)"]', 3, '定义域', '分母 x²-1 不能为 0,x=±1 不在定义域内,所以图像不可能经过 (1,2)。', 1, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (8, 'bank', '若 f(-x)=-f(x),且图像经过 (5,-2),则一定经过?', '["A. (-5,-2)", "B. (-5,2)", "C. (5,2)", "D. (2,5)"]', 1, '奇函数', '奇函数图像关于原点对称,(5,-2) 关于原点的对称点是 (-5,2)。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (9, 'bank', '若 f(-x)=f(x),且图像经过 (4,7),则下列点一定在图像上的是?', '["(-4,7)", "(-4,-7)", "(4,-7)", "(7,4)"]', 0, '偶函数', '由偶函数定义得到关于 y 轴的镜像点。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (10, 'bank', '若 f(-x)=-f(x),且图像经过 (3,5),则下列点一定在图像上的是?', '["(-3,5)", "(-3,-5)", "(3,-5)", "(5,3)"]', 1, '奇函数', '由奇函数定义得到关于原点的对称点。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (11, 'bank', '下列函数中属于偶函数的是?', '["$y=x^3$", "$y=x^2+1$", "$y=x+1$", "$y=x^3+x$"]', 1, '偶函数', '代入 $-x$ 检验 $f(-x)=f(x)$。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (12, 'bank', '下列函数中属于奇函数的是?', '["y=x²", "y=x^3", "y=x²+1", "y=x^3+1"]', 1, '奇函数', '代入 -x 检验 f(-x)=-f(x)。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (13, 'bank', '函数 y=1/(x-2) 的图像不可能经过哪个点?', '["(0,-1/2)", "(3,1)", "(2,0)", "(4,1/2)"]', 2, '定义域', '分母不能为 0,因此 x=2 不属于定义域。', 1, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (14, 'bank', '若一个函数图像关于 y 轴对称,则该函数通常满足?', '["f(-x)=f(x)", "f(-x)=-f(x)", "f(x)=0", "f(x)>0"]', 0, '图像对称性', '关于 y 轴对称对应偶函数。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (15, 'bank', '若一个函数图像关于原点对称,则该函数通常满足?', '["f(-x)=f(x)", "f(-x)=-f(x)", "f(x)=1", "f(x)>0"]', 1, '图像对称性', '关于原点对称对应奇函数。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (16, 'bank', '若 f(x) 是偶函数,且 f(-5)=9,则 f(5)=?', '["-9", "0", "9", "无法确定"]', 2, '偶函数', '偶函数的两个对称自变量对应相同函数值。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (17, 'bank', '若 f(x) 是奇函数,且 f(-6)=4,则 f(6)=?', '["4", "-4", "0", "无法确定"]', 1, '奇函数', '奇函数的两个相反自变量对应相反函数值。', 7, 1, 0, 1);
INSERT INTO questions (id, source, stem, options, correct_index, knowledge_name, explanation, chapter_id, difficulty, is_generated, used_count) VALUES (18, 'bank', '判断函数 y=x⁴-x² 的奇偶性。', '["偶函数", "奇函数", "既奇又偶", "非奇非偶"]', 0, '偶函数', '偶次幂组成,代入 -x 后表达式不变。', 7, 1, 0, 1);
-- question_knowledge: 18 行
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (1, 1, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (2, 2, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (3, 3, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (4, 4, 3);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (5, 5, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (6, 6, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (7, 7, 3);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (8, 8, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (9, 9, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (10, 10, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (11, 11, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (12, 12, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (13, 13, 3);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (14, 14, 4);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (15, 15, 4);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (16, 16, 1);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (17, 17, 2);
INSERT INTO question_knowledge (id, question_id, knowledge_id) VALUES (18, 18, 1);
COMMIT;
VACUUM;

View File

@ -22,6 +22,13 @@
--danger-soft: #fef2f2; --danger-soft: #fef2f2;
--danger-soft-border: #fecaca; --danger-soft-border: #fecaca;
--warning-soft: #fffbeb; --warning-soft: #fffbeb;
/* 内容页布局尺度:顶栏高度、内容列左右留白、内容列最大宽度 */
--layout-header-h: 56px;
--content-pad: 14px;
/* 底部留白单独一个变量:.app-content 的下边距比四周略大一点,
阅读器高度等「满屏高度」计算时统一引用它,避免出现几像素的竖直滚动条。 */
--content-pad-end: calc(var(--content-pad) + 4px);
--content-max: 1680px;
} }
/* 深色模式:只换中性色,品牌色与语义色保持不变 */ /* 深色模式:只换中性色,品牌色与语义色保持不变 */
@ -269,6 +276,13 @@ body {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
/* 与 .app-content-inner 同一条基线:内容列被 max-width 收窄时,
顶栏控件同步内收(推导:内容列左偏移 = pad + (100% - 2*pad - 上限)/2
= (100% - 上限)/2,未被夹住时就是 pad,故取两者较大值)。 */
padding-inline: max(
var(--content-pad),
calc((100% - var(--content-max)) / 2)
) !important;
background: var(--header-bg); background: var(--header-bg);
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
backdrop-filter: saturate(180%) blur(8px); backdrop-filter: saturate(180%) blur(8px);
@ -467,12 +481,33 @@ body {
color: var(--text-strong); color: var(--text-strong);
} }
/* 内容页边距:留白随视口变宽略微加大,但宽度优先给内容,
避免高分辨屏上「中间一条窄柱、两侧大片空白」。 */
@media (min-width: 900px) {
:root {
--content-pad: 20px;
}
}
@media (min-width: 1600px) {
:root {
--content-pad: 24px;
}
}
@media (min-width: 2200px) {
:root {
--content-pad: 28px;
}
}
.app-content { .app-content {
padding: 20px; padding: var(--content-pad);
padding-block-end: var(--content-pad-end);
} }
.app-content-inner { .app-content-inner {
max-width: 1360px; max-width: min(100%, var(--content-max));
margin: 0 auto; margin: 0 auto;
} }
@ -986,7 +1021,10 @@ body {
.ebook-reader { .ebook-reader {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 96px); /* 顶栏 + 内容区上下留白之外的全部空间,保证页面本身不产生竖直滚动 */
height: calc(
100vh - var(--layout-header-h) - var(--content-pad) - var(--content-pad-end)
);
min-height: 480px; min-height: 480px;
overflow: hidden; overflow: hidden;
background: var(--surface); background: var(--surface);
@ -1138,12 +1176,16 @@ body {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
:root {
--content-pad-end: calc(var(--content-pad) + 2px);
}
.app-content { .app-content {
padding: 14px; padding: var(--content-pad);
padding-block-end: var(--content-pad-end);
} }
.ebook-reader { .ebook-reader {
height: calc(100vh - 92px);
min-height: 420px; min-height: 420px;
} }
@ -1288,3 +1330,741 @@ body {
.daily-submit.ant-btn:disabled { .daily-submit.ant-btn:disabled {
box-shadow: none; box-shadow: none;
} }
/* ---------------- 分屏工作台(题库 / 记录 / 错题 / 答题) ---------------- */
.split-view {
display: flex;
align-items: stretch;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: 14px;
box-shadow: 0 1px 2px rgba(31, 32, 51, 0.04);
}
.split-aside {
flex: 0 0 auto;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface-2);
}
.split-view--left .split-aside {
border-inline-end: 1px solid var(--border-soft);
}
.split-view--right .split-aside {
border-inline-start: 1px solid var(--border-soft);
}
.split-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 窄屏:左右分屏退回上下堆叠,清单只占一小段高度 */
.split-view--stack {
flex-direction: column;
height: auto !important;
}
.split-view--stack .split-aside {
border-inline-end: 0;
border-inline-start: 0;
border-bottom: 1px solid var(--border-soft);
width: 100% !important;
max-height: 40vh;
}
.split-view--stack .split-main {
min-height: 60vh;
}
.split-resizer {
flex: 0 0 7px;
cursor: col-resize;
position: relative;
background: transparent;
touch-action: none;
}
.split-resizer::after {
content: "";
position: absolute;
inset: 0 3px;
border-radius: 3px;
background: var(--border-soft);
transition: background 0.16s ease;
}
.split-resizer:hover::after,
.split-resizer--active::after {
background: var(--brand);
}
body.split-dragging {
cursor: col-resize;
user-select: none;
}
/* 分屏里的一栏 */
.pane {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pane-head {
flex: 0 0 auto;
padding: 12px 14px;
border-bottom: 1px solid var(--border-soft);
background: var(--surface);
}
.split-aside > .pane > .pane-head {
background: var(--surface-2);
}
.pane-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.pane-title {
font-size: 14px;
font-weight: 600;
color: var(--text-strong);
min-width: 0;
}
.pane-extra {
flex-shrink: 0;
}
.pane-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.pane-toolbar:empty {
display: none;
}
/* 题库侧栏的三个筛选下拉尽量等分一行;栏太窄时整体换行,
而不是把「难度不限」压成「难度…」;
菜单宽度靠 popupMatchSelectWidth={false} 单独放开,长选项不会被裁。 */
.bank-filter-select {
flex: 1 1 92px;
min-width: 92px;
}
.pane-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 10px 12px 18px;
}
.pane--flat .pane-body {
padding: 0;
overflow: hidden;
display: flex;
}
.pane-foot {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid var(--border-soft);
background: var(--surface);
}
/* 清单条目 */
.master-item {
display: block;
width: 100%;
text-align: start;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease;
}
.master-item:hover {
background: var(--surface-hover);
}
.master-item--active {
background: var(--brand-soft);
border-color: rgba(91, 92, 226, 0.35);
}
.master-item-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--text-strong);
line-height: 1.5;
}
.master-item-sub {
margin-top: 3px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.master-group {
margin: 6px 0 4px;
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
/* 得分环 / 计数的数字都用等宽数字,纵向对比才整齐 */
.tabular {
font-variant-numeric: tabular-nums;
}
/* ---------------- 试卷排版 ---------------- */
.paper {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.paper-head {
flex: 0 0 auto;
padding: 14px 20px 10px;
border-bottom: 1px solid var(--border-soft);
background: var(--surface);
}
.paper-head-main {
min-width: 0;
}
.paper-title {
font-size: 16px;
font-weight: 650;
color: var(--text-strong);
line-height: 1.5;
}
.paper-meta {
margin-top: 2px;
font-size: 12.5px;
color: var(--text-secondary);
line-height: 1.7;
}
.paper-head-side {
flex-shrink: 0;
}
.paper-score {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 0 12px;
border-inline-start: 1px solid var(--border-soft);
}
.paper-score-value {
font-size: 22px;
font-weight: 700;
line-height: 1.1;
color: var(--brand);
font-variant-numeric: tabular-nums;
}
.paper-score-total {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.paper-score-label {
font-size: 11.5px;
color: var(--text-secondary);
}
.paper-head-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
}
.paper-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 14px 18px 24px;
}
/* 一道题 */
.paper-question {
display: flex;
gap: 12px;
padding: 14px 10px 16px;
border-radius: 12px;
transition: background 0.16s ease, box-shadow 0.16s ease;
}
.paper-question + .paper-question {
border-top: 1px dashed var(--border-soft);
border-radius: 12px 12px 0 0;
}
.paper-question--active {
background: var(--surface-2);
box-shadow: inset 3px 0 0 var(--brand);
}
.paper-question-rail {
flex: 0 0 auto;
padding-top: 1px;
}
.paper-question-no {
display: inline-grid;
place-items: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
border-radius: 50%;
background: var(--track);
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.paper-question--correct .paper-question-no {
background: var(--success-soft);
color: var(--success-soft-border);
color: #16a34a;
}
.paper-question--wrong .paper-question-no {
background: var(--danger-soft);
color: #dc2626;
}
.paper-question-main {
flex: 1 1 auto;
min-width: 0;
}
.paper-question-head {
min-height: 24px;
margin-bottom: 2px;
}
.paper-question-actions {
margin-inline-start: auto;
}
.paper-stem {
font-size: 15.5px;
font-weight: 600;
line-height: 1.85;
color: var(--text-strong);
margin: 6px 0 12px;
}
.paper-options {
max-width: 760px;
}
.paper-answer {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--surface-2);
border: 1px solid var(--border-soft);
max-width: 760px;
}
.paper-explain-body {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border-soft);
line-height: 1.85;
color: var(--text-strong);
}
.paper-explain-toggle {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
color: var(--brand);
font-size: 13px;
}
/* 答题卡 */
.answer-sheet {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.answer-sheet-chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.answer-chip {
position: relative;
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 32px;
height: 30px;
padding: 0 8px;
border: 1px solid var(--border-soft);
border-radius: 9px;
background: var(--surface);
color: var(--text-strong);
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.answer-chip:hover {
transform: translateY(-1px);
border-color: var(--brand);
}
.answer-chip--active {
border-color: var(--brand);
box-shadow: 0 0 0 2px var(--brand-soft);
}
.answer-chip-icon {
font-size: 11px;
}
.answer-chip--correct {
background: var(--success-soft);
border-color: var(--success-soft-border);
color: #16a34a;
}
.answer-chip--wrong {
background: var(--danger-soft);
border-color: var(--danger-soft-border);
color: #dc2626;
}
.answer-chip--done {
border-color: rgba(91, 92, 226, 0.45);
color: var(--brand);
}
.answer-chip--pending {
border-style: dashed;
color: var(--text-muted);
}
.answer-sheet-legend {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.answer-sheet-legend-toggle {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-decoration: underline dotted;
}
.answer-sheet-legend-items {
display: flex;
}
.answer-sheet-legend-items span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.answer-dot {
width: 9px;
height: 9px;
border-radius: 3px;
display: inline-block;
}
.answer-dot--correct {
background: var(--success-soft);
border: 1px solid #16a34a;
}
.answer-dot--wrong {
background: var(--danger-soft);
border: 1px solid #dc2626;
}
.answer-dot--done {
background: var(--brand-soft);
border: 1px solid var(--brand);
}
.answer-dot--pending {
background: transparent;
border: 1px dashed var(--text-muted);
}
.answer-sheet-summary {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
color: var(--text-secondary);
}
/* 答题中的提交条 */
.paper-submit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
/* 题库卡片里的章节导航 */
.chapter-nav-count {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
font-size: 12px;
}
/* 试卷头部:标题与视图切换并排 */
.paper-head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
/* 题干下方的主体(选项 / 作答控件) */
.paper-body-slot {
min-width: 0;
}
/* 可折叠解析 */
.paper-explain {
margin-top: 12px;
max-width: 760px;
}
.paper-explain-caret {
margin-inline-start: 6px;
font-size: 10px;
color: var(--text-muted);
}
/* 我的作答 / 正确答案 对照 */
.answer-compare {
align-items: center;
row-gap: 4px;
}
.answer-chip-no {
font-variant-numeric: tabular-nums;
}
/* 清单里的可点分组(教材名)与更紧凑的子项(章节) */
.master-group-button {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-strong);
font: inherit;
text-align: start;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.master-group-button:hover {
background: var(--surface-hover);
}
.master-group-button--active {
color: var(--brand);
}
.master-item--tight {
padding: 7px 10px;
margin-bottom: 2px;
border-radius: 8px;
}
.master-item--tight .master-item-title {
font-weight: 500;
font-size: 13px;
}
/* 侧栏底部的小字提示 */
.aside-hint {
margin-top: 14px;
padding: 10px 4px 0;
border-top: 1px dashed var(--border-soft);
line-height: 1.7;
}
/* 生成任务进度条(挂在分屏上方,高度变化由 ResizeObserver 收敛) */
.bank-task {
margin-bottom: 12px;
padding: 12px 16px 4px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: 12px;
}
/* 成绩单摘要 */
.result-score {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.result-score-value {
font-size: 30px;
font-weight: 700;
line-height: 1.1;
color: var(--brand);
font-variant-numeric: tabular-nums;
}
.result-score-total {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
}
.result-score-label {
font-size: 12px;
color: var(--text-secondary);
}
.result-knowledge {
margin-top: 14px;
padding-top: 4px;
border-top: 1px dashed var(--border-soft);
}
.result-knowledge-row {
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.result-knowledge-name {
flex: 1 1 auto;
min-width: 0;
color: var(--text-strong);
}
/* 章节测试:组卷条件收成上方一条窄栏,高度留给答题工作台 */
.test-launcher {
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: 14px;
box-shadow: 0 1px 2px rgba(31, 32, 51, 0.04);
}
.test-launcher-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.test-launcher-select {
width: 260px;
max-width: 100%;
}
.test-launcher-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border-soft);
}
.test-launcher-tags {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.test-launcher-note {
flex: 1 1 240px;
min-width: 0;
font-size: 12.5px;
}
@media (max-width: 1200px) {
.paper-head {
padding: 12px 14px 8px;
}
.paper-body {
padding: 14px 12px 26px;
}
.paper-question {
padding: 16px 6px 18px;
}
}

23
scripts/db.sh 100755
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# nex_math 数据库运维:备份 / 恢复 / 内容快照。
#
# ./scripts/db.sh backup [标签] 备份 data/math.db(seed --force 也会自动备份)
# ./scripts/db.sh list 列出本机备份
# ./scripts/db.sh restore [文件|latest] 用备份覆盖当前库(覆盖前先自动备份)
# ./scripts/db.sh dump [输出文件] 导出内容快照(教材/章节/知识点/课程/题库),可提交进 git
# ./scripts/db.sh load [快照文件] 把内容快照灌回当前库
# ./scripts/db.sh path 数据库文件位置
#
# 为什么要 dump:.gitignore 里 data/*.db 让库文件从来就没进过远端仓库,
# 库一重置,章节和题目就没了。把内容表导成 SQL 文本提交后,
# 远端仓库里始终有一份可恢复的数据库快照(且不含账号、答题记录等个人数据)。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PY="$ROOT_DIR/backend/.venv/bin/python"
if [ ! -x "$PY" ]; then
echo "[db] 未找到 backend/.venv,请先执行 ./scripts/init.sh" >&2
exit 1
fi
cd "$ROOT_DIR/backend"
exec "$PY" -m dbtool "$@"