Compare commits
2 Commits
92bdb20741
...
c06ca75c89
| Author | SHA1 | Date |
|---|---|---|
|
|
c06ca75c89 | |
|
|
5f72149ceb |
|
|
@ -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
135
README.md
|
|
@ -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`),重建教材条目并把文件挪回该书目录,不必重新上传。
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,48 +250,39 @@ 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_chapter_knowledge(db)
|
||||||
_sync_default_course_resources(db)
|
_sync_default_course_resources(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
return
|
|
||||||
|
|
||||||
for position, item in enumerate(CATALOG):
|
|
||||||
|
def _upsert_textbook(db: Session, item: dict, position: int) -> Textbook:
|
||||||
textbook = db.query(Textbook).filter(Textbook.name == item["name"]).first()
|
textbook = db.query(Textbook).filter(Textbook.name == item["name"]).first()
|
||||||
if textbook is None:
|
if textbook is None:
|
||||||
textbook = Textbook(name=item["name"], position=position)
|
textbook = Textbook(name=item["name"], position=position)
|
||||||
|
|
@ -299,41 +296,54 @@ def ensure_catalog(db: Session, force_sync: bool = False) -> None:
|
||||||
textbook.link = item["link"]
|
textbook.link = item["link"]
|
||||||
textbook.description = item["description"]
|
textbook.description = item["description"]
|
||||||
textbook.position = position
|
textbook.position = position
|
||||||
|
return textbook
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_chapters(db: Session, textbook: Textbook, chapters: list[dict]) -> None:
|
||||||
|
"""按官方目录补齐缺失章节(已有的同名章节原样保留)。"""
|
||||||
|
if not chapters:
|
||||||
|
return
|
||||||
existing = {
|
existing = {
|
||||||
chapter.name: chapter
|
chapter.name
|
||||||
for chapter in db.query(Chapter)
|
for chapter in db.query(Chapter)
|
||||||
.filter(Chapter.textbook_id == textbook.id)
|
.filter(Chapter.textbook_id == textbook.id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
for chapter_index, chapter in enumerate(item["chapters"]):
|
position = len(existing)
|
||||||
if chapter["name"] not in existing:
|
for chapter in chapters:
|
||||||
|
if chapter["name"] in existing:
|
||||||
|
continue
|
||||||
db.add(
|
db.add(
|
||||||
Chapter(
|
Chapter(
|
||||||
textbook_id=textbook.id,
|
textbook_id=textbook.id,
|
||||||
name=chapter["name"],
|
name=chapter["name"],
|
||||||
position=chapter_index,
|
position=position,
|
||||||
summary=chapter["summary"],
|
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()
|
primary = db.query(Textbook).filter(Textbook.name == CATALOG[0]["name"]).first()
|
||||||
if primary:
|
if primary is None:
|
||||||
|
return
|
||||||
chapter_map = {
|
chapter_map = {
|
||||||
chapter.name: chapter
|
chapter.name: chapter
|
||||||
for chapter in db.query(Chapter)
|
for chapter in db.query(Chapter)
|
||||||
.filter(Chapter.textbook_id == primary.id)
|
.filter(Chapter.textbook_id == primary.id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
for question in db.query(Question).all():
|
pending = db.query(Question).filter(Question.chapter_id.is_(None)).all()
|
||||||
chapter = chapter_map.get(
|
for question in pending:
|
||||||
KNOWLEDGE_CHAPTER_MAP.get(question.knowledge_name, "")
|
chapter = chapter_map.get(KNOWLEDGE_CHAPTER_MAP.get(question.knowledge_name, ""))
|
||||||
)
|
if chapter is not None:
|
||||||
question.chapter_id = chapter.id if chapter else None
|
question.chapter_id = chapter.id
|
||||||
_sync_default_chapter_knowledge(db)
|
|
||||||
_sync_default_course_resources(db)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_default_chapter_knowledge(db: Session) -> None:
|
def _sync_default_chapter_knowledge(db: Session) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "$@"
|
||||||
Loading…
Reference in New Issue