diff --git a/.gitignore b/.gitignore
index 0c619fd..7ba0194 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,9 @@ data/
node_modules/
vendor/
*.log
+
+# VitePress 文档站构建产物与缓存
+docs/.vitepress/dist/
+docs/.vitepress/cache/
.DS_Store
Thumbs.db
diff --git a/README.md b/README.md
index 5a66f6e..03062e7 100644
--- a/README.md
+++ b/README.md
@@ -14,15 +14,44 @@
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
| 缺考名单 | 名单中有、但未提交的人 |
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
-| 名单导入 | 上传 `.xlsx` / `.csv`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
-| 导出 | 成绩明细 CSV(学号/姓名/班级/成绩/提交时间/考试时长)、缺考名单 CSV(学号/姓名/班级) |
+| 名单导入 | 上传 `.xlsx` / `.xlsm` / `.csv` / `.txt`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
+| 导出 | 成绩明细 / 缺考名单 `.xlsx`(零依赖生成,支持多工作表、按最新成绩、按班级分表) |
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
## 环境要求
- PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 xlsx)
-- 无需数据库、无需 Composer 依赖,数据以 JSON 存放在 `data/`
+- 推荐启用 `pdo_sqlite`(用于 SQLite 存储);未启用时自动降级为 JSON 文件存储
+- 无需数据库服务、无需 Composer 依赖
+
+## 文档
+
+完整文档见 `docs/`(VitePress):
+
+```bash
+cd docs
+npm install
+npm run docs:dev # 本地预览
+npm run docs:build # 构建静态站到 docs/.vitepress/dist
+```
+
+## 安全
+
+- 登录凭据即卷王后台账密;连续 5 次失败锁定 10 分钟
+- 所有写接口强制校验会话绑定的 CSRF 令牌
+- 上传文件:扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)+ 随机文件名 + **解析后立即删除**
+- **部署时必须屏蔽 `data/` 目录**,否则配置、名单、卷王账密可被直接下载:
+
+```nginx
+# nginx 不读 .htaccess,必须显式配置
+location ^~ /data/ {
+ deny all;
+ return 404;
+}
+```
+
+详见 `docs/security/hardening.md`。
## 部署
@@ -76,7 +105,8 @@ Windows 也可双击 `启动平台.bat`。
- **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.xlsx` / `.csv`)。
- 名单文件可包含「学号」「姓名」两列,**「班级」为可选列**;含班级时,成绩明细会显示班级列并支持按班级筛选(一份考试涉及多个班级时很实用)。
- 上传方式:名单管理 → 点击或拖拽文件 → 选择覆盖 / 追加 → **保存名单**;也可手动增 / 删 / 改。
-- 名单数据保存在 `data/roster.json`。
+- 上传文件仅用于解析,落盘后**立即删除**,不会残留在 `data/uploads/`。
+- 名单数据保存在 SQLite `data/ny.db`(无 `pdo_sqlite` 时回落到 `data/roster.json`)。
## 目录结构
@@ -91,16 +121,18 @@ ny-exam-platform/
│ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化)
│ └─ XlsxReader.php 零依赖 XLSX 读取器
├─ assets/ 前端样式与脚本
-├─ data/ 配置、名单、令牌缓存(需可写)
-└─ tools/ 开发辅助(PHP 语法校验脚本)
+├─ data/ 配置、名单、SQLite 库、令牌缓存(需可写,且必须禁止 Web 直接访问)
+├─ docs/ VitePress 文档站
+├─ tests/ PHP 自测 + 一键运行脚本
+└─ tools/ 开发辅助(mock 服务、e2e 测试)
```
## 统计口径与显示说明
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
-- **每套试卷独立满分**:不同试卷满分不同,可在「设置 → 满分」中按当前选中的试卷单独设置,保存后仅作用于该试卷;未选中试卷时设置的满分作为所有试卷的默认值。合格率按「成绩 ≥ 满分 × 60%」计算。
-- **合格率**:成绩 ≥ 满分 × 60% 视为合格(满分见上)。
+- **每套试卷独立满分 / 合格率 / 免考**:在主页面「试卷设置」里为**当前选中的试卷**单独设置,试卷之间互相隔离;未设置时回落到「设置」里的全局默认值。
+- **合格率**:成绩 ≥ 满分 × 合格率阈值 视为合格;阈值全局默认 60%,可在「试卷设置」里按卷覆盖,卡片标题会实时显示当前阈值。
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
diff --git a/api.php b/api.php
index 49482f0..847fd57 100644
--- a/api.php
+++ b/api.php
@@ -472,9 +472,25 @@ try {
if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
ny_fail('请选择要导入的文件');
}
- $f = $_FILES['file'];
- $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
- $dest = NY_DATA . '/uploads/roster_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'dat');
+ $f = $_FILES['file'];
+ $err = (int)($f['error'] ?? UPLOAD_ERR_OK);
+ if ($err !== UPLOAD_ERR_OK) {
+ ny_fail('上传失败(错误码 ' . $err . ')');
+ }
+ if ((int)($f['size'] ?? 0) > 20 * 1024 * 1024) {
+ ny_fail('文件过大(上限 20MB)');
+ }
+ // 扩展名白名单:杜绝上传 .php/.phtml 等可执行后缀到 Web 目录
+ $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
+ if (!in_array($ext, ['xlsx', 'xlsm', 'csv', 'txt'], true)) {
+ ny_fail('仅支持 .xlsx / .xlsm / .csv / .txt 文件');
+ }
+ // 随机文件名:杜绝按时间枚举(原 roster_YYYYMMDD_HHMMSS.ext 可被遍历)
+ $dir = NY_DATA . '/uploads';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0777, true);
+ }
+ $dest = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!@move_uploaded_file($f['tmp_name'], $dest)) {
ny_fail('文件保存失败,请检查 data/uploads 目录权限');
}
@@ -484,6 +500,8 @@ try {
@unlink($dest);
ny_fail($e->getMessage());
}
+ // 解析完立即删除:Web 目录下不再留存任何上传文件
+ @unlink($dest);
$mode = (string)($_POST['mode'] ?? 'replace');
if ($mode === 'append') {
$merged = array_merge(Roster::all(), $res['students']);
diff --git a/bootstrap.php b/bootstrap.php
index dc91032..534d8c8 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -200,15 +200,19 @@ function ny_is_installed(): bool
}
// ---------------- 目录初始化 ----------------
-foreach (['', '/cache', '/uploads'] as $sub) {
+foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) {
$dir = NY_DATA . $sub;
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
-}
-// 保护数据目录(Apache)
-if (!file_exists(NY_DATA . '/.htaccess')) {
- @file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
+ // Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
+ if (!file_exists($dir . '/.htaccess')) {
+ @file_put_contents($dir . '/.htaccess', "Deny from all\n");
+ }
+ // 兜底:即使目录被索引也不暴露文件清单
+ if (!file_exists($dir . '/index.html')) {
+ @file_put_contents($dir . '/index.html', "
403 Forbidden403 Forbidden
\n");
+ }
}
// ---------------- 默认配置 ----------------
diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs
new file mode 100644
index 0000000..b550655
--- /dev/null
+++ b/docs/.vitepress/config.mjs
@@ -0,0 +1,129 @@
+import { defineConfig } from 'vitepress'
+
+// 部署到子路径时,构建前设置环境变量即可:
+// DOCS_BASE=/ny-exam-docs/ npm run docs:build
+const base = process.env.DOCS_BASE || '/'
+
+export default defineConfig({
+ base,
+ lang: 'zh-CN',
+ title: 'NY 问卷考试系统数据平台',
+ description: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 的零依赖考试数据统计平台',
+ cleanUrls: true,
+ ignoreDeadLinks: true,
+
+ head: [
+ ['meta', { name: 'theme-color', content: '#2563eb' }],
+ ['meta', { name: 'referrer', content: 'no-referrer' }]
+ ],
+
+ themeConfig: {
+ // outlineTitle: '本页目录',
+ outline: [2, 3],
+ lastUpdated: { text: '最后更新于' },
+
+ search: {
+ provider: 'local',
+ options: {
+ locales: {
+ zh: {
+ translations: {
+ button: { buttonText: '搜索文档', buttonAriaLabel: '搜索文档' },
+ modal: {
+ noResultsText: '未找到相关结果',
+ resetButtonTitle: '清除查询条件',
+ footer: {
+ selectText: '选择',
+ navigateText: '切换',
+ closeText: '关闭'
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ nav: [
+ { text: '首页', link: '/' },
+ { text: '使用指南', link: '/guide/introduction', activeMatch: '/guide/' },
+ { text: '安全', link: '/security/', activeMatch: '/security/' },
+ { text: '开发', link: '/dev/architecture', activeMatch: '/dev/' }
+ ],
+
+ sidebar: {
+ '/guide/': [
+ {
+ text: '开始',
+ items: [
+ { text: '平台简介', link: '/guide/introduction' },
+ { text: '环境要求与部署', link: '/guide/install' },
+ { text: '快速上手', link: '/guide/quickstart' }
+ ]
+ },
+ {
+ text: '核心功能',
+ items: [
+ { text: '系统设置', link: '/guide/settings' },
+ { text: '名单管理', link: '/guide/roster' },
+ { text: '成绩明细与统计口径', link: '/guide/scores' },
+ { text: '试卷设置(满分/合格率/免考)', link: '/guide/project-config' },
+ { text: '快照、趋势与异常检测', link: '/guide/snapshot' },
+ { text: '导出与打印', link: '/guide/export-print' }
+ ]
+ },
+ {
+ text: '其他页面',
+ items: [
+ { text: '数据看板', link: '/guide/dashboard' },
+ { text: '随机抽取', link: '/guide/draw' }
+ ]
+ },
+ {
+ text: '帮助',
+ items: [
+ { text: '常见问题', link: '/guide/faq' }
+ ]
+ }
+ ],
+ '/security/': [
+ {
+ text: '安全',
+ items: [
+ { text: '安全总览', link: '/security/' },
+ { text: '部署加固清单', link: '/security/hardening' }
+ ]
+ }
+ ],
+ '/dev/': [
+ {
+ text: '开发',
+ items: [
+ { text: '架构与目录', link: '/dev/architecture' },
+ { text: '接口一览', link: '/dev/api' },
+ { text: '数据存储(SQLite)', link: '/dev/database' },
+ { text: '测试', link: '/dev/testing' }
+ ]
+ }
+ ]
+ },
+
+ socialLinks: [],
+
+ footer: {
+ message: '基于 PHP 8 + 卷王(SurveyKing)社区版开放 API 构建,零第三方运行依赖。',
+ copyright: 'Copyright © NY 问卷考试系统数据平台'
+ },
+
+ docFooter: {
+ prev: '上一页',
+ next: '下一页'
+ },
+
+ returnToTopLabel: '回到顶部',
+ sidebarMenuLabel: '菜单',
+ darkModeSwitchLabel: '主题',
+ lightModeSwitchTitle: '切换到浅色模式',
+ darkModeSwitchTitle: '切换到深色模式'
+ }
+})
diff --git a/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs b/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs
new file mode 100644
index 0000000..8d08e13
--- /dev/null
+++ b/docs/.vitepress/config.mjs.timestamp-1789051213693-e9e6067deebec.mjs
@@ -0,0 +1,121 @@
+// .vitepress/config.mjs
+import { defineConfig } from "file:///C:/Users/Zhang/Documents/jc/ny-exam-platform/docs/node_modules/vitepress/dist/node/index.js";
+var base = process.env.DOCS_BASE || "/";
+var config_default = defineConfig({
+ base,
+ lang: "zh-CN",
+ title: "NY \u95EE\u5377\u8003\u8BD5\u7CFB\u7EDF\u6570\u636E\u5E73\u53F0",
+ description: "\u57FA\u4E8E PHP 8 + \u5377\u738B\uFF08SurveyKing\uFF09\u793E\u533A\u7248\u5F00\u653E API \u7684\u96F6\u4F9D\u8D56\u8003\u8BD5\u6570\u636E\u7EDF\u8BA1\u5E73\u53F0",
+ cleanUrls: true,
+ ignoreDeadLinks: true,
+ head: [
+ ["meta", { name: "theme-color", content: "#2563eb" }],
+ ["meta", { name: "referrer", content: "no-referrer" }]
+ ],
+ themeConfig: {
+ // outlineTitle: '本页目录',
+ outline: [2, 3],
+ lastUpdated: { text: "\u6700\u540E\u66F4\u65B0\u4E8E" },
+ search: {
+ provider: "local",
+ options: {
+ locales: {
+ zh: {
+ translations: {
+ button: { buttonText: "\u641C\u7D22\u6587\u6863", buttonAriaLabel: "\u641C\u7D22\u6587\u6863" },
+ modal: {
+ noResultsText: "\u672A\u627E\u5230\u76F8\u5173\u7ED3\u679C",
+ resetButtonTitle: "\u6E05\u9664\u67E5\u8BE2\u6761\u4EF6",
+ footer: {
+ selectText: "\u9009\u62E9",
+ navigateText: "\u5207\u6362",
+ closeText: "\u5173\u95ED"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ nav: [
+ { text: "\u9996\u9875", link: "/" },
+ { text: "\u4F7F\u7528\u6307\u5357", link: "/guide/introduction", activeMatch: "/guide/" },
+ { text: "\u5B89\u5168", link: "/security/", activeMatch: "/security/" },
+ { text: "\u5F00\u53D1", link: "/dev/architecture", activeMatch: "/dev/" }
+ ],
+ sidebar: {
+ "/guide/": [
+ {
+ text: "\u5F00\u59CB",
+ items: [
+ { text: "\u5E73\u53F0\u7B80\u4ECB", link: "/guide/introduction" },
+ { text: "\u73AF\u5883\u8981\u6C42\u4E0E\u90E8\u7F72", link: "/guide/install" },
+ { text: "\u5FEB\u901F\u4E0A\u624B", link: "/guide/quickstart" }
+ ]
+ },
+ {
+ text: "\u6838\u5FC3\u529F\u80FD",
+ items: [
+ { text: "\u7CFB\u7EDF\u8BBE\u7F6E", link: "/guide/settings" },
+ { text: "\u540D\u5355\u7BA1\u7406", link: "/guide/roster" },
+ { text: "\u6210\u7EE9\u660E\u7EC6\u4E0E\u7EDF\u8BA1\u53E3\u5F84", link: "/guide/scores" },
+ { text: "\u8BD5\u5377\u8BBE\u7F6E\uFF08\u6EE1\u5206/\u5408\u683C\u7387/\u514D\u8003\uFF09", link: "/guide/project-config" },
+ { text: "\u5FEB\u7167\u3001\u8D8B\u52BF\u4E0E\u5F02\u5E38\u68C0\u6D4B", link: "/guide/snapshot" },
+ { text: "\u5BFC\u51FA\u4E0E\u6253\u5370", link: "/guide/export-print" }
+ ]
+ },
+ {
+ text: "\u5176\u4ED6\u9875\u9762",
+ items: [
+ { text: "\u6570\u636E\u770B\u677F", link: "/guide/dashboard" },
+ { text: "\u968F\u673A\u62BD\u53D6", link: "/guide/draw" }
+ ]
+ },
+ {
+ text: "\u5E2E\u52A9",
+ items: [
+ { text: "\u5E38\u89C1\u95EE\u9898", link: "/guide/faq" }
+ ]
+ }
+ ],
+ "/security/": [
+ {
+ text: "\u5B89\u5168",
+ items: [
+ { text: "\u5B89\u5168\u603B\u89C8", link: "/security/" },
+ { text: "\u90E8\u7F72\u52A0\u56FA\u6E05\u5355", link: "/security/hardening" }
+ ]
+ }
+ ],
+ "/dev/": [
+ {
+ text: "\u5F00\u53D1",
+ items: [
+ { text: "\u67B6\u6784\u4E0E\u76EE\u5F55", link: "/dev/architecture" },
+ { text: "\u63A5\u53E3\u4E00\u89C8", link: "/dev/api" },
+ { text: "\u6570\u636E\u5B58\u50A8\uFF08SQLite\uFF09", link: "/dev/database" },
+ { text: "\u6D4B\u8BD5", link: "/dev/testing" }
+ ]
+ }
+ ]
+ },
+ socialLinks: [],
+ footer: {
+ message: "\u57FA\u4E8E PHP 8 + \u5377\u738B\uFF08SurveyKing\uFF09\u793E\u533A\u7248\u5F00\u653E API \u6784\u5EFA\uFF0C\u96F6\u7B2C\u4E09\u65B9\u8FD0\u884C\u4F9D\u8D56\u3002",
+ copyright: "Copyright \xA9 NY \u95EE\u5377\u8003\u8BD5\u7CFB\u7EDF\u6570\u636E\u5E73\u53F0"
+ },
+ docFooter: {
+ prev: "\u4E0A\u4E00\u9875",
+ next: "\u4E0B\u4E00\u9875"
+ },
+ returnToTopLabel: "\u56DE\u5230\u9876\u90E8",
+ sidebarMenuLabel: "\u83DC\u5355",
+ darkModeSwitchLabel: "\u4E3B\u9898",
+ lightModeSwitchTitle: "\u5207\u6362\u5230\u6D45\u8272\u6A21\u5F0F",
+ darkModeSwitchTitle: "\u5207\u6362\u5230\u6DF1\u8272\u6A21\u5F0F"
+ }
+});
+export {
+ config_default as default
+};
+//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLnZpdGVwcmVzcy9jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcWmhhbmdcXFxcRG9jdW1lbnRzXFxcXGpjXFxcXG55LWV4YW0tcGxhdGZvcm1cXFxcZG9jc1xcXFwudml0ZXByZXNzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFxaaGFuZ1xcXFxEb2N1bWVudHNcXFxcamNcXFxcbnktZXhhbS1wbGF0Zm9ybVxcXFxkb2NzXFxcXC52aXRlcHJlc3NcXFxcY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovVXNlcnMvWmhhbmcvRG9jdW1lbnRzL2pjL255LWV4YW0tcGxhdGZvcm0vZG9jcy8udml0ZXByZXNzL2NvbmZpZy5tanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlcHJlc3MnXG5cbi8vIFx1OTBFOFx1N0Y3Mlx1NTIzMFx1NUI1MFx1OERFRlx1NUY4NFx1NjVGNlx1RkYwQ1x1Njc4NFx1NUVGQVx1NTI0RFx1OEJCRVx1N0Y2RVx1NzNBRlx1NTg4M1x1NTNEOFx1OTFDRlx1NTM3M1x1NTNFRlx1RkYxQVxuLy8gICBET0NTX0JBU0U9L255LWV4YW0tZG9jcy8gbnBtIHJ1biBkb2NzOmJ1aWxkXG5jb25zdCBiYXNlID0gcHJvY2Vzcy5lbnYuRE9DU19CQVNFIHx8ICcvJ1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBiYXNlLFxuICBsYW5nOiAnemgtQ04nLFxuICB0aXRsZTogJ05ZIFx1OTVFRVx1NTM3N1x1ODAwM1x1OEJENVx1N0NGQlx1N0VERlx1NjU3MFx1NjM2RVx1NUU3M1x1NTNGMCcsXG4gIGRlc2NyaXB0aW9uOiAnXHU1N0ZBXHU0RThFIFBIUCA4ICsgXHU1Mzc3XHU3MzhCXHVGRjA4U3VydmV5S2luZ1x1RkYwOVx1NzkzRVx1NTMzQVx1NzI0OFx1NUYwMFx1NjUzRSBBUEkgXHU3Njg0XHU5NkY2XHU0RjlEXHU4RDU2XHU4MDAzXHU4QkQ1XHU2NTcwXHU2MzZFXHU3RURGXHU4QkExXHU1RTczXHU1M0YwJyxcbiAgY2xlYW5VcmxzOiB0cnVlLFxuICBpZ25vcmVEZWFkTGlua3M6IHRydWUsXG5cbiAgaGVhZDogW1xuICAgIFsnbWV0YScsIHsgbmFtZTogJ3RoZW1lLWNvbG9yJywgY29udGVudDogJyMyNTYzZWInIH1dLFxuICAgIFsnbWV0YScsIHsgbmFtZTogJ3JlZmVycmVyJywgY29udGVudDogJ25vLXJlZmVycmVyJyB9XVxuICBdLFxuXG4gIHRoZW1lQ29uZmlnOiB7XG4gICAgLy8gb3V0bGluZVRpdGxlOiAnXHU2NzJDXHU5ODc1XHU3NkVFXHU1RjU1JyxcbiAgICBvdXRsaW5lOiBbMiwgM10sXG4gICAgbGFzdFVwZGF0ZWQ6IHsgdGV4dDogJ1x1NjcwMFx1NTQwRVx1NjZGNFx1NjVCMFx1NEU4RScgfSxcblxuICAgIHNlYXJjaDoge1xuICAgICAgcHJvdmlkZXI6ICdsb2NhbCcsXG4gICAgICBvcHRpb25zOiB7XG4gICAgICAgIGxvY2FsZXM6IHtcbiAgICAgICAgICB6aDoge1xuICAgICAgICAgICAgdHJhbnNsYXRpb25zOiB7XG4gICAgICAgICAgICAgIGJ1dHRvbjogeyBidXR0b25UZXh0OiAnXHU2NDFDXHU3RDIyXHU2NTg3XHU2ODYzJywgYnV0dG9uQXJpYUxhYmVsOiAnXHU2NDFDXHU3RDIyXHU2NTg3XHU2ODYzJyB9LFxuICAgICAgICAgICAgICBtb2RhbDoge1xuICAgICAgICAgICAgICAgIG5vUmVzdWx0c1RleHQ6ICdcdTY3MkFcdTYyN0VcdTUyMzBcdTc2RjhcdTUxNzNcdTdFRDNcdTY3OUMnLFxuICAgICAgICAgICAgICAgIHJlc2V0QnV0dG9uVGl0bGU6ICdcdTZFMDVcdTk2NjRcdTY3RTVcdThCRTJcdTY3NjFcdTRFRjYnLFxuICAgICAgICAgICAgICAgIGZvb3Rlcjoge1xuICAgICAgICAgICAgICAgICAgc2VsZWN0VGV4dDogJ1x1OTAwOVx1NjJFOScsXG4gICAgICAgICAgICAgICAgICBuYXZpZ2F0ZVRleHQ6ICdcdTUyMDdcdTYzNjInLFxuICAgICAgICAgICAgICAgICAgY2xvc2VUZXh0OiAnXHU1MTczXHU5NUVEJ1xuICAgICAgICAgICAgICAgIH1cbiAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0sXG5cbiAgICBuYXY6IFtcbiAgICAgIHsgdGV4dDogJ1x1OTk5Nlx1OTg3NScsIGxpbms6ICcvJyB9LFxuICAgICAgeyB0ZXh0OiAnXHU0RjdGXHU3NTI4XHU2MzA3XHU1MzU3JywgbGluazogJy9ndWlkZS9pbnRyb2R1Y3Rpb24nLCBhY3RpdmVNYXRjaDogJy9ndWlkZS8nIH0sXG4gICAgICB7IHRleHQ6ICdcdTVCODlcdTUxNjgnLCBsaW5rOiAnL3NlY3VyaXR5LycsIGFjdGl2ZU1hdGNoOiAnL3NlY3VyaXR5LycgfSxcbiAgICAgIHsgdGV4dDogJ1x1NUYwMFx1NTNEMScsIGxpbms6ICcvZGV2L2FyY2hpdGVjdHVyZScsIGFjdGl2ZU1hdGNoOiAnL2Rldi8nIH1cbiAgICBdLFxuXG4gICAgc2lkZWJhcjoge1xuICAgICAgJy9ndWlkZS8nOiBbXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1RjAwXHU1OUNCJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1RTczXHU1M0YwXHU3QjgwXHU0RUNCJywgbGluazogJy9ndWlkZS9pbnRyb2R1Y3Rpb24nIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTczQUZcdTU4ODNcdTg5ODFcdTZDNDJcdTRFMEVcdTkwRThcdTdGNzInLCBsaW5rOiAnL2d1aWRlL2luc3RhbGwnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVGRUJcdTkwMUZcdTRFMEFcdTYyNEInLCBsaW5rOiAnL2d1aWRlL3F1aWNrc3RhcnQnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU2ODM4XHU1RkMzXHU1MjlGXHU4MEZEJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU3Q0ZCXHU3RURGXHU4QkJFXHU3RjZFJywgbGluazogJy9ndWlkZS9zZXR0aW5ncycgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NTQwRFx1NTM1NVx1N0JBMVx1NzQwNicsIGxpbms6ICcvZ3VpZGUvcm9zdGVyJyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2MjEwXHU3RUU5XHU2NjBFXHU3RUM2XHU0RTBFXHU3RURGXHU4QkExXHU1M0UzXHU1Rjg0JywgbGluazogJy9ndWlkZS9zY29yZXMnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdThCRDVcdTUzNzdcdThCQkVcdTdGNkVcdUZGMDhcdTZFRTFcdTUyMDYvXHU1NDA4XHU2ODNDXHU3Mzg3L1x1NTE0RFx1ODAwM1x1RkYwOScsIGxpbms6ICcvZ3VpZGUvcHJvamVjdC1jb25maWcnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVGRUJcdTcxNjdcdTMwMDFcdThEOEJcdTUyQkZcdTRFMEVcdTVGMDJcdTVFMzhcdTY4QzBcdTZENEInLCBsaW5rOiAnL2d1aWRlL3NuYXBzaG90JyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1QkZDXHU1MUZBXHU0RTBFXHU2MjUzXHU1MzcwJywgbGluazogJy9ndWlkZS9leHBvcnQtcHJpbnQnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1MTc2XHU0RUQ2XHU5ODc1XHU5NzYyJyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2NTcwXHU2MzZFXHU3NzBCXHU2NzdGJywgbGluazogJy9ndWlkZS9kYXNoYm9hcmQnIH0sXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTk2OEZcdTY3M0FcdTYyQkRcdTUzRDYnLCBsaW5rOiAnL2d1aWRlL2RyYXcnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICB0ZXh0OiAnXHU1RTJFXHU1MkE5JyxcbiAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgeyB0ZXh0OiAnXHU1RTM4XHU4OUMxXHU5NUVFXHU5ODk4JywgbGluazogJy9ndWlkZS9mYXEnIH1cbiAgICAgICAgICBdXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICAnL3NlY3VyaXR5Lyc6IFtcbiAgICAgICAge1xuICAgICAgICAgIHRleHQ6ICdcdTVCODlcdTUxNjgnLFxuICAgICAgICAgIGl0ZW1zOiBbXG4gICAgICAgICAgICB7IHRleHQ6ICdcdTVCODlcdTUxNjhcdTYwM0JcdTg5QzgnLCBsaW5rOiAnL3NlY3VyaXR5LycgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1OTBFOFx1N0Y3Mlx1NTJBMFx1NTZGQVx1NkUwNVx1NTM1NScsIGxpbms6ICcvc2VjdXJpdHkvaGFyZGVuaW5nJyB9XG4gICAgICAgICAgXVxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgJy9kZXYvJzogW1xuICAgICAgICB7XG4gICAgICAgICAgdGV4dDogJ1x1NUYwMFx1NTNEMScsXG4gICAgICAgICAgaXRlbXM6IFtcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjdCNlx1Njc4NFx1NEUwRVx1NzZFRVx1NUY1NScsIGxpbms6ICcvZGV2L2FyY2hpdGVjdHVyZScgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjNBNVx1NTNFM1x1NEUwMFx1ODlDOCcsIGxpbms6ICcvZGV2L2FwaScgfSxcbiAgICAgICAgICAgIHsgdGV4dDogJ1x1NjU3MFx1NjM2RVx1NUI1OFx1NTBBOFx1RkYwOFNRTGl0ZVx1RkYwOScsIGxpbms6ICcvZGV2L2RhdGFiYXNlJyB9LFxuICAgICAgICAgICAgeyB0ZXh0OiAnXHU2RDRCXHU4QkQ1JywgbGluazogJy9kZXYvdGVzdGluZycgfVxuICAgICAgICAgIF1cbiAgICAgICAgfVxuICAgICAgXVxuICAgIH0sXG5cbiAgICBzb2NpYWxMaW5rczogW10sXG5cbiAgICBmb290ZXI6IHtcbiAgICAgIG1lc3NhZ2U6ICdcdTU3RkFcdTRFOEUgUEhQIDggKyBcdTUzNzdcdTczOEJcdUZGMDhTdXJ2ZXlLaW5nXHVGRjA5XHU3OTNFXHU1MzNBXHU3MjQ4XHU1RjAwXHU2NTNFIEFQSSBcdTY3ODRcdTVFRkFcdUZGMENcdTk2RjZcdTdCMkNcdTRFMDlcdTY1QjlcdThGRDBcdTg4NENcdTRGOURcdThENTZcdTMwMDInLFxuICAgICAgY29weXJpZ2h0OiAnQ29weXJpZ2h0IFx1MDBBOSBOWSBcdTk1RUVcdTUzNzdcdTgwMDNcdThCRDVcdTdDRkJcdTdFREZcdTY1NzBcdTYzNkVcdTVFNzNcdTUzRjAnXG4gICAgfSxcblxuICAgIGRvY0Zvb3Rlcjoge1xuICAgICAgcHJldjogJ1x1NEUwQVx1NEUwMFx1OTg3NScsXG4gICAgICBuZXh0OiAnXHU0RTBCXHU0RTAwXHU5ODc1J1xuICAgIH0sXG5cbiAgICByZXR1cm5Ub1RvcExhYmVsOiAnXHU1NkRFXHU1MjMwXHU5ODc2XHU5MEU4JyxcbiAgICBzaWRlYmFyTWVudUxhYmVsOiAnXHU4M0RDXHU1MzU1JyxcbiAgICBkYXJrTW9kZVN3aXRjaExhYmVsOiAnXHU0RTNCXHU5ODk4JyxcbiAgICBsaWdodE1vZGVTd2l0Y2hUaXRsZTogJ1x1NTIwN1x1NjM2Mlx1NTIzMFx1NkQ0NVx1ODI3Mlx1NkEyMVx1NUYwRicsXG4gICAgZGFya01vZGVTd2l0Y2hUaXRsZTogJ1x1NTIwN1x1NjM2Mlx1NTIzMFx1NkRGMVx1ODI3Mlx1NkEyMVx1NUYwRidcbiAgfVxufSlcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBOFcsU0FBUyxvQkFBb0I7QUFJM1ksSUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhO0FBRXRDLElBQU8saUJBQVEs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
diff --git a/docs/dev/api.md b/docs/dev/api.md
new file mode 100644
index 0000000..18c92c1
--- /dev/null
+++ b/docs/dev/api.md
@@ -0,0 +1,106 @@
+# 接口一览
+
+所有接口统一走 `api.php?action=`,响应为 JSON:
+
+```jsonc
+// 成功
+{ "ok": true, "data": { ... } }
+// 失败
+{ "ok": false, "error": "错误信息" }
+```
+
+- 未登录时返回 `{ "ok": false, "error": "...", "auth": false }`,前端会清会话并跳登录页;
+- **写操作**必须在 JSON 体里带 `_csrf` 字段(或 `X-CSRF-Token` 头),
+ 令牌从 `config_get` 接口获取。
+
+## 写操作白名单
+
+`config_save`、`roster_save`、`roster_import`、`roster_clear`、
+`snapshot_save`、`snapshot_delete`、`unmatched_map`、`project_config_save`
+
+## 接口清单
+
+### 认证与配置
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `auth_check` | GET | — | 是否已登录 |
+| `config_get` | GET | — | 返回配置(密码不回显)+ `csrf_token` + `storage` / `db_driver` / `db_connected` |
+| `config_save` | POST | JSON 配置对象 | 保存;返回 `conn_changed` 与 `ping` 结果 |
+| `config_test` | POST | 连接信息 | 测试连接,不落库 |
+| `ping` | GET | — | 探测卷王是否可达 |
+| `diag` | GET | — | 诊断信息(存储后端、DB 状态等) |
+
+> **只有连接信息(地址 / 前缀 / 账号 / 密码 / 证书校验)变化时**,`config_save` 才会清除令牌并重新登录。
+
+### 数据
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `projects` | GET | — | 卷王项目(问卷 / 考试)列表 |
+| `data` | GET | `projectId`、`nameQid`、`force` | 主系统数据:统计、成绩行、名单、缺考、未匹配、异常 |
+| `dashboard` | GET | `projectId`、`nameQid`、`force` | 按班级 / 组别聚合的看板数据 |
+
+`data` 响应关键字段:
+
+```
+stats { total, submitted, absent, rate, avg, pass_rate, max, min, avg_duration }
+rows[] { sid, name, cls, grp, score, best_score, attempts, attempts_list[],
+ submit_ts, submit_at, last_submit_ts, duration, duration_txt }
+roster[] { sid, name, cls, grp, login }
+absent[] 缺考名单
+unmatched[] 未匹配答卷
+anomalies { fast_high[], frequent[], identical[] }
+project_cfg 本卷配置
+_cache { cached, at }
+```
+
+### 名单
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `roster` | GET | — | 名单与元信息 |
+| `roster_import` | POST | `multipart`:`file`、`mode=replace\|append` | 导入(扩展名白名单 + 随机名 + 解析后即删) |
+| `roster_save` | POST | JSON `{ students: [...] }` | 全量替换(DELETE + INSERT) |
+| `roster_clear` | POST | — | 清空 |
+| `unmatched_map` | POST | JSON `{ name, sid, cls, grp, login }` | 未匹配答卷补入名单(学号已存在则更新姓名) |
+
+### 每卷配置
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `project_config` | GET | `projectId` | 返回 `cfg`、`effective`(生效值)、`global`(全局默认) |
+| `project_config_save` | POST | JSON `{ full_score, pass_rate, exempt[] }` | 留空/0 = 清除该项,回落全局 |
+
+### 快照
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `snapshot_save` | POST | `projectId`、JSON `{ label }` | 保存当前成绩快照 |
+| `snapshot_list` | GET | `projectId` | 列表 |
+| `snapshot_delete` | POST | `projectId`、JSON `{ file }` | 删除 |
+| `snapshot_trend` | GET | `projectId`(可逗号分隔多个,跨卷对比) | 趋势 |
+
+### 导出
+
+| Action | 方法 | 参数 | 说明 |
+| --- | --- | --- | --- |
+| `export` | GET | `projectId`、`cls`、`grp`、`mode=latest`、`split=cls`、`sort`、`cols` | 成绩明细 `.xlsx` |
+| `export_absent` | GET | 同上 | 缺考名单 `.xlsx` |
+| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
+
+`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
+
+## 前端调用约定
+
+```js
+// 读
+const d = await api('data', { params: { projectId, force: 1 } })
+// 写:api() 会自动注入 _csrf
+await api('roster_save', { json: { students } })
+```
+
+## 相关
+
+- [架构与目录](./architecture.md)
+- [测试](./testing.md)
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
new file mode 100644
index 0000000..175f495
--- /dev/null
+++ b/docs/dev/architecture.md
@@ -0,0 +1,110 @@
+# 架构与目录
+
+## 技术栈
+
+- **后端**:PHP 8(过程式 + 少量静态类),零 Composer 依赖
+- **前端**:原生 HTML / CSS / JavaScript(无框架、无构建步骤)
+- **存储**:SQLite(PDO),缺失 `pdo_sqlite` 时自动降级为 JSON 文件
+- **外部依赖**:仅卷王(SurveyKing)开放 API
+
+## 目录结构
+
+```
+ny-exam-platform/
+├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
+├─ dashboard.php 数据看板页面
+├─ draw.php 随机抽取页面
+├─ login.php / logout.php 登录与登出
+├─ api.php 所有后端接口(单一入口,?action=xxx)
+├─ bootstrap.php 常量、自动加载、配置读写、CSRF、限流、公共函数
+├─ lib/
+│ ├─ SurveyKingClient.php 卷王 API 客户端(登录 / 令牌 / 项目 / 答卷)
+│ ├─ DataService.php 数据组装:姓名→学号匹配、统计、去重、异常检测
+│ ├─ Roster.php 名单管理(xlsx/csv 导入、列识别、持久化)
+│ ├─ Db.php SQLite 封装(三方言兼容的 DDL 与 upsert)
+│ ├─ DataCache.php 结果缓存(TTL + flock 防惊群)
+│ ├─ Snapshot.php 成绩快照与趋势
+│ ├─ XlsxReader.php 零依赖 XLSX 读取
+│ └─ XlsxWriter.php 零依赖 XLSX 写入(支持多工作表)
+├─ assets/ style.css / app.js / draw.js
+├─ data/ 运行时数据(需可写,已 gitignore)
+│ ├─ ny.db SQLite 主库
+│ ├─ config.json 配置(文件备份)
+│ ├─ roster.json 名单(文件备份)
+│ ├─ project_config.json 每卷配置(文件备份)
+│ ├─ cache/ 卷王令牌 + 结果缓存
+│ ├─ uploads/ 导入临时目录(解析后即删)
+│ └─ snapshots/ 成绩快照
+├─ tests/ PHP 自测 + 一键运行脚本
+├─ tools/ mock 服务与 Node e2e
+└─ docs/ 本文档站(VitePress)
+```
+
+## 请求流程
+
+```
+浏览器 ──► api.php?action=data
+ │
+ ├─► ny_is_auth() 未登录 → 401 + {auth:false}
+ ├─► 写操作 → ny_csrf_ok() 校验
+ ├─► ny_build_data()(带结果缓存)
+ │ └─► DataService::build()
+ │ ├─► SurveyKingClient::listAnswers() 拉卷王答卷
+ │ ├─► Roster::all() 读名单
+ │ ├─► 姓名 → 学号匹配
+ │ ├─► dedupe(决定代表成绩)
+ │ └─► 统计 / 异常检测
+ └─► ny_resolve_score_cfg() 解析本卷满分与合格率
+ └─► ny_ok(JSON)
+```
+
+## 关键设计
+
+### 单一入口 + 白名单
+
+`api.php` 用 `switch ($action)` 分发。所有写操作集中在 `$WRITE_ACTIONS` 白名单里统一校验 CSRF,
+新增写接口时**必须**把 action 加进该数组。
+
+### 配置双写
+
+`ny_save_config()` 同时写 SQLite 与 `data/config.json`;`ny_load_config()` 优先读库,
+库为空时自动从文件迁回。这样即使数据库不可用,也不会静默回落到出厂的 `admin/123456`。
+
+### 只在连接信息变化时才重新登录
+
+`config_save` 会比较 `base_url / api_prefix / username / password / verify_ssl` 五项,
+只有真变化才删除令牌缓存并重新 ping。修改满分、布局、刷新间隔不会打断数据刷新。
+
+### 结果缓存
+
+`DataCache` 以「配置 + 名单指纹 + 试卷 ID」为键缓存构建结果,默认 TTL 15 秒;
+用 `flock` 防止并发惊群。名单指纹(`Roster::fingerprint()`)参与缓存键,改名单会自动失效。
+点「立即刷新」带 `force=1` 跳过缓存。
+
+### 内存防护
+
+拉取卷王答卷时单页条数被夹在 20–200 之间,避免单页响应过大导致
+`json_decode` 一次性撑爆内存(曾出现过 128M 耗尽的问题)。
+
+### 零依赖 XLSX
+
+- 读取:`XlsxReader` 直接解析 ZIP(优先 `ZipArchive`,无 `zip` 扩展时用内置实现)与 XML;
+- 写入:`XlsxWriter` 手写 ZIP(stored 模式),单元格统一写共享字符串 +
+ `numFmtId=49` 文本样式,天然防公式注入、防长学号变科学计数法;
+- `renderMulti()` 支持多工作表,表名做 31 字截断与 `(2)` 去重。
+
+## 前端要点
+
+| 主题 | 实现 |
+| --- | --- |
+| 大表格 | 虚拟滚动(占位行 + 可视区渲染),`ROW_H=41`,overscan 12 |
+| 自动刷新 | `setTimeout` 链 + 失败指数退避;`visibilitychange` 时后台暂停 |
+| 提示 | 顶部 Toast 系统,`showAlert(msg, type)` 4 种颜色,最多 5 条 |
+| 全屏 | `document.exitFullscreen()`(注意不是 `documentElement`) |
+| 事件绑定 | 表格行点击用**事件委托**挂在 `#tbody` 上,避免内联 `onclick` 作用域问题 |
+
+## 相关
+
+- [接口一览](./api.md)
+- [数据存储(SQLite)](./database.md)
+- [测试](./testing.md)
diff --git a/docs/dev/database.md b/docs/dev/database.md
new file mode 100644
index 0000000..00118c6
--- /dev/null
+++ b/docs/dev/database.md
@@ -0,0 +1,113 @@
+# 数据存储(SQLite)
+
+平台使用 **SQLite 单文件数据库** `data/ny.db`,零配置、无需额外服务。
+
+## 启用条件与降级
+
+```php
+Db::isConfigured() // 是否有 pdo_sqlite 扩展
+Db::isConnected() // 是否真的连上了
+Db::lastError() // 失败原因
+```
+
+| 环境 | 行为 |
+| --- | --- |
+| 有 `pdo_sqlite` | 使用 `data/ny.db`(同时继续双写 JSON 文件作为备份) |
+| 无 `pdo_sqlite` | 整体降级为 JSON 文件存储,功能不受影响 |
+
+> 前端可通过 `config_get` 返回的 `storage`(`db` / `file`)判断当前用的是哪种。
+
+## 连接参数
+
+连接建立时施加的 PRAGMA:
+
+```sql
+PRAGMA journal_mode = WAL; -- 读写并发
+PRAGMA synchronous = NORMAL;
+PRAGMA busy_timeout = 5000; -- 写锁等待 5 秒
+PRAGMA foreign_keys = ON;
+```
+
+连接**每请求只尝试一次**,失败后返回 `null` 而非抛异常,保证上层能优雅降级。
+
+## 表结构
+
+所有建表 / 加列都是**幂等**的(`CREATE TABLE IF NOT EXISTS` + `PRAGMA table_info` 探测后 `ALTER TABLE`),可重复执行。
+
+### `ny_settings` — 全局配置
+
+| 列 | 类型 | 说明 |
+| --- | --- | --- |
+| `k` | TEXT PK | 键,目前只有 `config` |
+| `v` | TEXT | 整份配置的 JSON |
+| `updated_at` | — | 更新时间 |
+
+### `ny_roster` — 学号名单
+
+| 列 | 类型 | 说明 |
+| --- | --- | --- |
+| `sid` | TEXT PK | 学号 |
+| `name` | TEXT | 姓名 |
+| `cls` | TEXT | 班级 |
+| `grp` | TEXT | 组别 |
+| `login` | TEXT | 登陆账号(仅用于抽取页白名单导出) |
+| `sort` | INTEGER | 排序 |
+
+> `login` 列是后期加入的,旧库会在启动时自动 `ALTER TABLE ADD COLUMN`(幂等)。
+
+### `ny_project_config` — 每卷独立配置
+
+| 列 | 类型 | 说明 |
+| --- | --- | --- |
+| `project_id` | TEXT PK | 卷王试卷 ID |
+| `config_json` | TEXT | `{ full_score, pass_rate, exempt[] }` |
+| `updated_at` | — | 更新时间 |
+
+## 迁移策略
+
+首次在有 SQLite 的环境运行时:
+
+1. `ny_settings` 为空 → 从 `data/config.json` 迁入;
+2. `ny_roster` 为空 → 从 `data/roster.json` 迁入(`Roster::migrateFileToDbOnce()`);
+3. 每卷配置同理从 `data/project_config.json` 迁入。
+
+**迁移只发生在目标表为空时**,不会覆盖已入库的数据;重复执行安全。
+
+## 读写路径
+
+| 数据 | 主存储 | 备份 |
+| --- | --- | --- |
+| 配置 | `ny_settings['config']` | `data/config.json` |
+| 名单 | `ny_roster` | `data/roster.json` |
+| 每卷配置 | `ny_project_config` | `data/project_config.json` |
+| 成绩快照 | 文件系统 `data/snapshots/` | — |
+| 结果缓存 | 文件系统 `data/cache/` | — |
+| 卷王令牌 | 文件系统 `data/cache/token.json` | — |
+
+`ny_save_config()` 双写:先写库,再写文件。这样即使数据库突然不可用,也不会静默回落到出厂默认值。
+
+## 维护
+
+```bash
+# 备份(推荐:直接复制文件)
+cp data/ny.db data/ny.db.bak
+
+# 完整性检查
+php -r '$p=new PDO("sqlite:data/ny.db"); var_dump($p->query("PRAGMA integrity_check")->fetchAll());'
+
+# 手动查看名单
+php -r '$p=new PDO("sqlite:data/ny.db"); print_r($p->query("SELECT * FROM ny_roster LIMIT 5")->fetchAll(PDO::FETCH_ASSOC));'
+```
+
+::: tip 命令行加载扩展
+PHP CLI 常常不加载 Web 端已启用的扩展,需要显式指定:
+
+```bash
+php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite script.php
+```
+:::
+
+## 相关
+
+- [架构与目录](./architecture.md)
+- [接口一览](./api.md)
diff --git a/docs/dev/testing.md b/docs/dev/testing.md
new file mode 100644
index 0000000..08634bd
--- /dev/null
+++ b/docs/dev/testing.md
@@ -0,0 +1,98 @@
+# 测试
+
+回归测试分两类:**PHP 自测**(纯逻辑断言)与 **Node e2e**(起 mock 服务 + PHP 内置服务器,走真实 HTTP)。
+
+## 一键运行
+
+```bash
+bash tests/run-all.sh
+```
+
+脚本会依次执行:
+
+| 文件 | 类型 | 覆盖 |
+| --- | --- | --- |
+| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
+| `tests/test-new-features.php` | PHP | 异常检测、快照、趋势、XLSX 多表 |
+| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包 |
+| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
+| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
+
+可通过环境变量覆盖运行时路径:
+
+```bash
+PHP_BIN=/usr/bin/php NODE_BIN=$(which node) bash tests/run-all.sh
+```
+
+## 单独运行 PHP 自测
+
+```bash
+php -d extension_dir="C:/php/ext" -d extension=pdo_sqlite tests/test-aggregate.php
+php tests/test-new-features.php
+```
+
+输出为逐条 `✓ / ✗` 断言,结尾给出通过数 / 总数,失败时非零退出。
+
+## 单独运行 Node e2e
+
+e2e 需要两个进程:
+
+1. **mock 卷王服务**(必须在 `tools/` 目录下启动,内部用 `__dirname` 解析相对路径):
+
+```bash
+cd tools
+node mock-server.js # 默认监听 19911
+```
+
+2. **PHP 内置服务器**(端口 18081):
+
+```bash
+php -S 127.0.0.1:18081
+```
+
+然后:
+
+```bash
+node tools/test-config-save.js
+node tools/test-draw-alloc.js
+```
+
+::: warning 测试会污染 `data/`
+e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
+并在结束后恢复。若中途异常中断,请手动检查并还原:
+
+```bash
+git status data/ # data/ 已被 gitignore,需手工核对
+```
+
+另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
+:::
+
+## 写新测试时注意
+
+| 陷阱 | 说明 |
+| --- | --- |
+| CSRF | 写接口必须带 `_csrf`,e2e 里先从 `config_get` 取全局 token |
+| DB 优先 | 配置读的是 **DB 优先**,只备份 `config.json` 不够,必须一起备份 `data/ny.db` |
+| `/tmp` 路径 | Node 在 Windows 下会把 `/tmp/x.js` 解析成 `C:\tmp\x.js`,脚本请写在项目目录内 |
+| `json_decode` | 必须传 `true` 得到关联数组,否则 `is_array(stdClass)` 为 `false` 会静默跳过 |
+| mock 账号 | mock-server 接受账号 `mock` |
+
+## 手工冒烟清单
+
+改完代码建议至少过一遍:
+
+- [ ] 登录 → 主页面三张卡片有数字
+- [ ] 切换班级 → 卡片与表格联动
+- [ ] 点某一行 → 能看到该学生的每次提交
+- [ ] 导入含 `.php` 后缀的文件 → 被拒绝
+- [ ] 导入正常 `.xlsx` → 成功,且 `data/uploads/` 为空
+- [ ] 导出成绩 / 导出缺考 → 能打开,学号不是科学计数法
+- [ ] 打印预览 → 默认成绩降序
+- [ ] 数据看板全屏进入 / 退出都正常
+- [ ] 随机抽取 → 名额守恒
+
+## 相关
+
+- [架构与目录](./architecture.md)
+- [接口一览](./api.md)
diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md
new file mode 100644
index 0000000..3a43d01
--- /dev/null
+++ b/docs/guide/dashboard.md
@@ -0,0 +1,48 @@
+# 数据看板
+
+数据看板(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
+
+从主页面顶部导航 **📊 数据看板** 打开(新标签页)。
+
+## 顶部操作
+
+| 控件 | 说明 |
+| --- | --- |
+| 问卷 / 考试 | 选择要展示的试卷 |
+| 班级 | 选择后只展示该班级(需先选试卷) |
+| 自动刷新开关 + 间隔 | 与主页面一致的 5 / 10 / 15 / 30 / 60 / 120 秒 |
+| 刷新 | 立即拉取一次(跳过缓存) |
+| ⛶ 全屏 | 进入 / 退出全屏,布局自动铺满 |
+
+## 汇总卡片
+
+`总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率(≥X%满分)`
+
+合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
+
+## 班级 / 组别下钻
+
+看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
+总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
+
+点击班级内任意成员可查看其**每一次提交**(时间、成绩、时长),最高分有标记。
+
+> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
+
+## 常见问题
+
+### 全屏按钮点了没反应
+
+- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝;
+- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`),
+ 平台已修正过这一点;
+- 若页面被嵌在 `