Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87986fcc6 | ||
|
|
d05fd4a9b5 | ||
|
|
0ba9c7c33c | ||
|
|
f661862d46 | ||
|
|
555f500dc7 | ||
|
|
eb5d06d392 | ||
|
|
94ac381579 | ||
|
|
804b04de60 | ||
|
|
25a2e8a42c | ||
|
|
f271066266 | ||
|
|
58f9c8433a | ||
|
|
a3be81b261 | ||
|
|
157f257728 | ||
|
|
0fd880624f | ||
|
|
87750b20c4 | ||
|
|
a65ec682ce | ||
|
|
6def49b969 | ||
|
|
137e2cb665 | ||
|
|
e031b3858e | ||
|
|
0174b2abc2 | ||
|
|
14c7fa7177 | ||
|
|
9ee2aec2f0 | ||
|
|
c4c3bf7da1 | ||
|
|
d2f0908385 | ||
|
|
3952c77afe | ||
|
|
6cd17b9cb2 |
@@ -1,9 +1,21 @@
|
|||||||
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# WorkBuddy 项目数据目录(记忆 / 配置缓存),非源码,禁止入库
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
|
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
|
||||||
|
.ny-test-data/
|
||||||
|
|
||||||
# 依赖与系统文件
|
# 依赖与系统文件
|
||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# VitePress 文档站构建产物与缓存
|
||||||
|
docs/.vitepress/dist/
|
||||||
|
docs/.vitepress/dist*/
|
||||||
|
docs/.vitepress/cache/
|
||||||
|
docs/.vitepress/config.mjs.timestamp-*.mjs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -14,15 +14,44 @@
|
|||||||
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
|
| 统计卡片 | 名单总人数、已提交、缺考、提交率、平均分、**合格率**(≥60% 满分)、最高分、最低分、平均用时;**选择班级后,全部卡片只统计该班级** |
|
||||||
| 缺考名单 | 名单中有、但未提交的人 |
|
| 缺考名单 | 名单中有、但未提交的人 |
|
||||||
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
|
| 未匹配答卷 | 提交了但姓名不在名单中的答卷,方便排查 |
|
||||||
| 名单导入 | 上传 `.xlsx` / `.csv`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
|
| 名单导入 | 上传 `.xlsx` / `.xlsm` / `.csv` / `.txt`,自动识别「学号」「姓名」「班级」列(班级可选);支持覆盖 / 追加、手动增删改 |
|
||||||
| 导出 | 成绩明细 CSV(学号/姓名/班级/成绩/提交时间/考试时长)、缺考名单 CSV(学号/姓名/班级) |
|
| 导出 | 成绩明细 / 缺考名单 `.xlsx`(零依赖生成,支持多工作表、按最新成绩、按班级分表) |
|
||||||
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
|
| 排序 / 班级 | 点击表头排序,**成绩明细默认按提交时间降序**;支持学号/姓名/班级/成绩/提交时间/时长排序;按班级下拉筛选 |
|
||||||
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
|
| 全屏 / 自适应 | 右上角「全屏」按钮进入全屏(布局自动铺满、表格更高);页面适配手机 / 平板 / 桌面三端 |
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- PHP ≥ 8.0(需启用 `curl`、`json`、`mbstring`、`openssl`(可选,用于 RSA 加密密码);`zip` 可选,无 zip 扩展时会用内置解析器读 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`)。
|
- **平台启动时不预设任何名单**,请通过右上角 **名单管理** 上传你自己的名单(`.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 导入与持久化)
|
│ ├─ Roster.php 学号名单管理(xlsx/csv 导入与持久化)
|
||||||
│ └─ XlsxReader.php 零依赖 XLSX 读取器
|
│ └─ XlsxReader.php 零依赖 XLSX 读取器
|
||||||
├─ assets/ 前端样式与脚本
|
├─ assets/ 前端样式与脚本
|
||||||
├─ data/ 配置、名单、令牌缓存(需可写)
|
├─ data/ 配置、名单、SQLite 库、令牌缓存(需可写,且必须禁止 Web 直接访问)
|
||||||
└─ tools/ 开发辅助(PHP 语法校验脚本)
|
├─ docs/ VitePress 文档站
|
||||||
|
├─ tests/ PHP 自测 + 一键运行脚本
|
||||||
|
└─ tools/ 开发辅助(mock 服务、e2e 测试)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 统计口径与显示说明
|
## 统计口径与显示说明
|
||||||
|
|
||||||
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
|
- **班级联动统计**:顶部「班级」下拉选择某一班级后,统计卡片(总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高分 / 最低分 / 平均用时)与成绩明细、缺考名单都只统计该班级;选「全部班级」则统计整体。切换班级在本地即时重算,不额外请求服务器。
|
||||||
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
|
- **面板计数随班级**:选择班级后,成绩明细标题右侧的「共 X 人已提交 · 名单 X 人」、缺考名单标题右侧的「共 X 人」也都只统计该班级(不再用全部班级的数据)。
|
||||||
- **每套试卷独立满分**:不同试卷满分不同,可在「设置 → 满分」中按当前选中的试卷单独设置,保存后仅作用于该试卷;未选中试卷时设置的满分作为所有试卷的默认值。合格率按「成绩 ≥ 满分 × 60%」计算。
|
- **每套试卷独立满分 / 合格率 / 免考**:在主页面「试卷设置」里为**当前选中的试卷**单独设置,试卷之间互相隔离;未设置时回落到「设置」里的全局默认值。
|
||||||
- **合格率**:成绩 ≥ 满分 × 60% 视为合格(满分见上)。
|
- **合格率**:成绩 ≥ 满分 × 合格率阈值 视为合格;阈值全局默认 60%,可在「试卷设置」里按卷覆盖,卡片标题会实时显示当前阈值。
|
||||||
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
|
- **平均用时不换行**:平均用时(如「9 分 55 秒」)强制单行显示,避免「秒」字换行。
|
||||||
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
|
- **成绩明细不再显示每人的「状态」列**:缺考人员统一在「缺考名单」面板查看,成绩明细只保留学号 / 姓名 / 班级 / 成绩 / 提交时间 / 考试时长。
|
||||||
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
|
- **新增数据标记**:每次刷新后,相较上一次新出现的提交(按学号判断)整行显示绿色底纹,方便一眼看出谁刚交卷;首次加载不标记。
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
history: new Set(), // 累计抽中(重置结果时清零)
|
history: new Set(), // 累计抽中(重置结果时清零)
|
||||||
excluded: new Set(), // 手动排除
|
excluded: new Set(), // 手动排除
|
||||||
log: [], // 历史批次,新的在前
|
log: [], // 历史批次,新的在前
|
||||||
|
customRatios: {}, // 自定义比例:{ 组名: 权重 }
|
||||||
drawing: false,
|
drawing: false,
|
||||||
|
all: false, // 「全部」模式:人数始终跟随当前候选池(手工改输入框即解除)
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- 接口 ---------------- */
|
/* ---------------- 接口 ---------------- */
|
||||||
@@ -56,12 +58,33 @@
|
|||||||
return j.data;
|
return j.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Toast 顶部飘窗通知(与 app.js 一致) ---------------- */
|
||||||
|
const TOAST_DURATION = 3500;
|
||||||
|
const TOAST_LIMIT = 5;
|
||||||
|
function showToast(msg, type) {
|
||||||
|
if (msg === '' || msg == null) return;
|
||||||
|
const el = document.getElementById('toastContainer') || (function () {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.id = 'toastContainer';
|
||||||
|
c.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:calc(100vw - 32px);width:560px';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
return c;
|
||||||
|
})();
|
||||||
|
while (el.children.length >= TOAST_LIMIT) el.firstChild.remove();
|
||||||
|
const t = document.createElement('div');
|
||||||
|
const cls = type === 'error' ? 'toast-error' : (type === 'warn' ? 'toast-warn' : (type === 'info' ? 'toast-ok' : 'toast'));
|
||||||
|
t.className = 'toast ' + cls;
|
||||||
|
t.innerHTML = '<span>' + esc(msg).replace(/\n/g, '<br>') + '</span>';
|
||||||
|
t.style.cssText = 'pointer-events:auto;padding:10px 16px;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 30px rgba(15,23,42,.18);animation:toastIn .25s ease;white-space:pre-wrap;word-break:break-word';
|
||||||
|
el.appendChild(t);
|
||||||
|
t.addEventListener('click', () => { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); });
|
||||||
|
setTimeout(() => { if (t.parentNode) { t.style.animation = 'toastOut .2s ease forwards'; setTimeout(() => t.remove(), 200); } }, TOAST_DURATION);
|
||||||
|
}
|
||||||
|
/** 兼容旧 showAlert 调用 → 走 toast(draw.js 内部已有 esc,此处直接复用) */
|
||||||
function showAlert(msg, type) {
|
function showAlert(msg, type) {
|
||||||
const el = $('#alert');
|
if (msg === '' || msg == null) return;
|
||||||
if (!el) return;
|
const t = type === 'info' ? 'ok' : (type === 'error' ? 'error' : (type === 'warn' ? 'warn' : ''));
|
||||||
if (!msg) { el.classList.add('hidden'); return; }
|
showToast(msg, t);
|
||||||
el.textContent = msg;
|
|
||||||
el.className = 'alert' + (type === 'info' ? ' info' : '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 抽取核心(纯函数,供测试) ================= */
|
/* ================= 抽取核心(纯函数,供测试) ================= */
|
||||||
@@ -77,38 +100,91 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 名额均分到各组(5:5 语义):每组 n/组数,余数轮流补,
|
* 名额分配:
|
||||||
* 某组人数不足时把差额退回分给未满的组(结果之和 === min(n, 总人数))
|
* - sizes 各组容量
|
||||||
|
* - n 要抽的总人数
|
||||||
|
* - weights 各组权重(null=均分;数组=按权重,权重<=0 视为 0)
|
||||||
|
* 算法:最大余额法(largest remainder)—— raw_i = n * w_i / Σw;floor 后把剩余名额
|
||||||
|
* 按小数部分降序分给还没满的组。均分模式是 weights=null 的特例(每组权重 1),
|
||||||
|
* 余数时按"组容量小者优先"补齐,保证每组都有代表。
|
||||||
*/
|
*/
|
||||||
function allocQuotas(sizes, n) {
|
function allocQuotas(sizes, n, weights) {
|
||||||
const g = sizes.length;
|
const g = sizes.length;
|
||||||
const quota = new Array(g).fill(0);
|
const quota = new Array(g).fill(0);
|
||||||
if (g === 0 || n <= 0) return quota;
|
if (g === 0 || n <= 0) return quota;
|
||||||
|
const total = sizes.reduce((a, b) => a + b, 0);
|
||||||
|
if (n >= total) return sizes.slice(); // 全量
|
||||||
|
|
||||||
// 1) 均分初值 + 余数补齐(小组优先,保证每组有代表)
|
const base = new Array(g).fill(0);
|
||||||
const base = Math.floor(n / g);
|
const frac = new Array(g).fill(0);
|
||||||
quota.fill(base);
|
|
||||||
let r = n - base * g;
|
|
||||||
const bySmall = sizes.map((s, i) => i).sort((a, b) => sizes[a] - sizes[b]);
|
|
||||||
for (let k = 0; k < r; k++) quota[bySmall[k % g]]++;
|
|
||||||
|
|
||||||
// 2) 超过组容量的退回
|
if (Array.isArray(weights) && weights.length === g) {
|
||||||
let overflow = 0;
|
const w = weights.map(x => (Number(x) > 0 ? Number(x) : 0));
|
||||||
|
const wsum = w.reduce((a, b) => a + b, 0);
|
||||||
|
if (wsum > 0) {
|
||||||
|
// 按权重分配:最大余额法
|
||||||
for (let i = 0; i < g; i++) {
|
for (let i = 0; i < g; i++) {
|
||||||
if (quota[i] > sizes[i]) { overflow += quota[i] - sizes[i]; quota[i] = sizes[i]; }
|
const raw = n * w[i] / wsum;
|
||||||
|
base[i] = Math.floor(raw);
|
||||||
|
frac[i] = raw - base[i];
|
||||||
}
|
}
|
||||||
// 3) 差额分给未满的组(剩余容量大者优先)
|
} else {
|
||||||
while (overflow > 0) {
|
// 全 0 权重 → 退化为均分
|
||||||
const byRoom = sizes.map((s, i) => i)
|
const b = Math.floor(n / g);
|
||||||
.sort((a, b) => (sizes[b] - quota[b]) - (sizes[a] - quota[a]));
|
for (let i = 0; i < g; i++) base[i] = b;
|
||||||
|
const rem = n - b * g;
|
||||||
|
const bySmall = sizes.map((s, i) => i)
|
||||||
|
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
|
||||||
|
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 名额均分:每组 n/g,余数小组优先(保证每组都有代表)
|
||||||
|
const b = Math.floor(n / g);
|
||||||
|
for (let i = 0; i < g; i++) base[i] = b;
|
||||||
|
const rem = n - b * g;
|
||||||
|
const bySmall = sizes.map((s, i) => i)
|
||||||
|
.sort((a, b2) => sizes[a] - sizes[b2] || a - b2);
|
||||||
|
for (let k = 0; k < rem; k++) base[bySmall[k % g]]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 余数补齐:frac 大的优先;同 frac 时剩余容量大的优先
|
||||||
|
let rest = n - base.reduce((a, b) => a + b, 0);
|
||||||
|
if (rest > 0) {
|
||||||
|
const order = sizes.map((s, i) => i).sort((a, b) =>
|
||||||
|
(frac[b] - frac[a]) || ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||||
|
for (let k = 0; rest > 0 && k < g; k++) {
|
||||||
|
const i = order[k % g];
|
||||||
|
if (base[i] < sizes[i]) { base[i]++; rest--; }
|
||||||
|
}
|
||||||
|
// 若仍有余数(组容量限制),继续按剩余容量大者分配
|
||||||
|
while (rest > 0) {
|
||||||
let progress = false;
|
let progress = false;
|
||||||
|
const byRoom = sizes.map((s, i) => i)
|
||||||
|
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||||
for (const i of byRoom) {
|
for (const i of byRoom) {
|
||||||
if (overflow <= 0) break;
|
if (rest <= 0) break;
|
||||||
if (quota[i] < sizes[i]) { quota[i]++; overflow--; progress = true; }
|
if (base[i] < sizes[i]) { base[i]++; rest--; progress = true; }
|
||||||
}
|
}
|
||||||
if (!progress) break;
|
if (!progress) break;
|
||||||
}
|
}
|
||||||
return quota;
|
}
|
||||||
|
|
||||||
|
// 超容量退回(极端比例时兜底)
|
||||||
|
let overflow = 0;
|
||||||
|
for (let i = 0; i < g; i++) {
|
||||||
|
if (base[i] > sizes[i]) { overflow += base[i] - sizes[i]; base[i] = sizes[i]; }
|
||||||
|
}
|
||||||
|
while (overflow > 0) {
|
||||||
|
let progress = false;
|
||||||
|
const byRoom = sizes.map((s, i) => i)
|
||||||
|
.sort((a, b) => ((sizes[b] - base[b]) - (sizes[a] - base[a])) || (a - b));
|
||||||
|
for (const i of byRoom) {
|
||||||
|
if (overflow <= 0) break;
|
||||||
|
if (base[i] < sizes[i]) { base[i]++; overflow--; progress = true; }
|
||||||
|
}
|
||||||
|
if (!progress) break;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,10 +199,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分组均衡抽取:返回 { picked, detail }
|
* 分组均衡抽取:返回 { picked, detail, sizes, weights }
|
||||||
* detail 例:["A 3人", "B 3人"],用于提示与历史记录
|
* weights 数组为各组实际权重(默认加权时=各组人数)
|
||||||
|
* detail 例:["A 6人", "B 4人"],用于提示与历史记录
|
||||||
*/
|
*/
|
||||||
function pickBalanced(pool, n, key) {
|
function pickBalanced(pool, n, key, weights) {
|
||||||
const label = key === 'grp' ? '(未分组)' : '(未分班)';
|
const label = key === 'grp' ? '(未分组)' : '(未分班)';
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
pool.forEach(m => {
|
pool.forEach(m => {
|
||||||
@@ -137,7 +214,10 @@
|
|||||||
const gk = Array.from(groups.keys())
|
const gk = Array.from(groups.keys())
|
||||||
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
||||||
const sizes = gk.map(k => groups.get(k).length);
|
const sizes = gk.map(k => groups.get(k).length);
|
||||||
const quotas = allocQuotas(sizes, n);
|
const w = (Array.isArray(weights) && weights.length === gk.length)
|
||||||
|
? weights
|
||||||
|
: null;
|
||||||
|
const quotas = allocQuotas(sizes, n, w);
|
||||||
const picked = [];
|
const picked = [];
|
||||||
const detail = [];
|
const detail = [];
|
||||||
gk.forEach((k, i) => {
|
gk.forEach((k, i) => {
|
||||||
@@ -145,7 +225,7 @@
|
|||||||
picked.push(...part);
|
picked.push(...part);
|
||||||
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
|
if (quotas[i] > 0) detail.push(k + ' ' + quotas[i] + '人');
|
||||||
});
|
});
|
||||||
return { picked, detail };
|
return { picked, detail, sizes, weights: w || sizes.slice() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
|
// 测试钩子(node 端 eval 后通过 window.__nyDrawTest 调用)
|
||||||
@@ -266,6 +346,11 @@
|
|||||||
|
|
||||||
function renderStats() {
|
function renderStats() {
|
||||||
const pool = currentPool();
|
const pool = currentPool();
|
||||||
|
// 「全部」模式:候选池变化(改筛选 / 改来源 / 改排除)时人数跟着变
|
||||||
|
if (state.all) {
|
||||||
|
const ni = $('#numInput');
|
||||||
|
if (ni) ni.value = String(Math.max(1, pool.length));
|
||||||
|
}
|
||||||
$('#dPool').textContent = pool.length;
|
$('#dPool').textContent = pool.length;
|
||||||
$('#dPicked').textContent = state.picked.length;
|
$('#dPicked').textContent = state.picked.length;
|
||||||
$('#dHistory').textContent = state.history.size;
|
$('#dHistory').textContent = state.history.size;
|
||||||
@@ -278,6 +363,36 @@
|
|||||||
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
|
if (ec) ec.textContent = '已排除 ' + state.excluded.size + ' 人';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当前候选池的分组信息:{ key, gk[], sizes[] }
|
||||||
|
* key = 'grp' | 'cls' | ''
|
||||||
|
* gk 按字典序排序后的组名
|
||||||
|
* sizes 每组成员数
|
||||||
|
*/
|
||||||
|
function poolGroups(pool) {
|
||||||
|
const key = balanceKeyOf(pool);
|
||||||
|
if (!key) return { key: '', gk: [], sizes: [] };
|
||||||
|
const label = key === 'grp' ? '(未分组)' : '(未分班)';
|
||||||
|
const groups = new Map();
|
||||||
|
pool.forEach(m => {
|
||||||
|
const k = String(m[key] || '').trim() || label;
|
||||||
|
if (!groups.has(k)) groups.set(k, 0);
|
||||||
|
groups.set(k, groups.get(k) + 1);
|
||||||
|
});
|
||||||
|
const gk = Array.from(groups.keys())
|
||||||
|
.sort((a, b) => String(a).localeCompare(String(b), 'zh', { numeric: true }));
|
||||||
|
return { key, gk, sizes: gk.map(k => groups.get(k)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把对象 {gk:weight} 读成与当前池分组顺序一致的数组,缺组视为 0 */
|
||||||
|
function readCustomWeights(gk) {
|
||||||
|
const src = state.customRatios || {};
|
||||||
|
return gk.map(k => {
|
||||||
|
const v = parseFloat(src[k]);
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= 抽取 ================= */
|
/* ================= 抽取 ================= */
|
||||||
async function doDraw() {
|
async function doDraw() {
|
||||||
if (state.drawing) return;
|
if (state.drawing) return;
|
||||||
@@ -286,9 +401,11 @@
|
|||||||
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
showAlert('当前筛选下没有可抽取的人员(可关闭「排除已抽中」、调整班级 / 组别或排除名单)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let n = parseInt($('#numInput').value, 10);
|
// 「全部」模式:人数始终等于当前候选池,切换筛选后不会沿用旧数字
|
||||||
|
let n = state.all ? pool.length : parseInt($('#numInput').value, 10);
|
||||||
if (!n || n < 1) n = 1;
|
if (!n || n < 1) n = 1;
|
||||||
if (n > pool.length) n = pool.length;
|
let capped = false;
|
||||||
|
if (n > pool.length) { n = pool.length; capped = true; }
|
||||||
|
|
||||||
state.drawing = true;
|
state.drawing = true;
|
||||||
const stage = $('#drawStage');
|
const stage = $('#drawStage');
|
||||||
@@ -305,38 +422,53 @@
|
|||||||
await sleep(40 + i * 7);
|
await sleep(40 + i * 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真正抽取:均衡(组别优先,其次班级)或整池随机
|
// 真正抽取:均衡(按模式分组) 或整池随机
|
||||||
let picked, detail = [];
|
let picked, detail = [];
|
||||||
if ($('#balanceChk').checked) {
|
const mode = $('#balanceMode').value;
|
||||||
const key = balanceKeyOf(pool);
|
if (mode === 'off') {
|
||||||
if (key) {
|
picked = shuffle(pool).slice(0, n);
|
||||||
const res = pickBalanced(pool, n, key);
|
} else {
|
||||||
|
const g = poolGroups(pool);
|
||||||
|
if (g.key) {
|
||||||
|
let weights = null;
|
||||||
|
if (mode === 'weight') weights = g.sizes.slice(); // 默认按组人数加权
|
||||||
|
else if (mode === 'custom') weights = readCustomWeights(g.gk);
|
||||||
|
// mode === 'even' 走 allocQuotas(null) → 均分
|
||||||
|
const res = pickBalanced(pool, n, g.key, weights);
|
||||||
picked = res.picked; detail = res.detail;
|
picked = res.picked; detail = res.detail;
|
||||||
} else {
|
} else {
|
||||||
picked = shuffle(pool).slice(0, n);
|
picked = shuffle(pool).slice(0, n);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
picked = shuffle(pool).slice(0, n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.picked = picked;
|
state.picked = picked;
|
||||||
picked.forEach(m => state.history.add(String(m.sid)));
|
picked.forEach(m => state.history.add(String(m.sid)));
|
||||||
|
|
||||||
stage.className = 'draw-stage done';
|
stage.className = 'draw-stage done';
|
||||||
stage.textContent = n === 1
|
if (n === 1 && picked.length) {
|
||||||
? (picked[0].name || picked[0].sid)
|
// 只抽 1 人时把学号 / 班级 / 组别一起显示出来,教师不用再去表里找
|
||||||
: ('🎉 已抽取 ' + n + ' 人');
|
const p0 = picked[0];
|
||||||
|
const meta = [p0.sid, p0.cls, p0.grp ? (p0.grp + '组') : ''].filter(Boolean).join(' · ');
|
||||||
|
stage.innerHTML = '<div>' + esc(p0.name || p0.sid) +
|
||||||
|
(meta ? '<div class="draw-stage-sub">' + esc(meta) + '</div>' : '') + '</div>';
|
||||||
|
} else {
|
||||||
|
stage.textContent = '🎉 已抽取 ' + n + ' 人';
|
||||||
|
}
|
||||||
|
|
||||||
renderResult();
|
renderResult();
|
||||||
renderStats();
|
renderStats();
|
||||||
pushLog(picked, detail);
|
pushLog(picked, detail, mode);
|
||||||
saveState();
|
saveState();
|
||||||
|
|
||||||
state.drawing = false;
|
state.drawing = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
const tail = detail.length ? '(' + detail.join(' / ') + ')' : '';
|
||||||
|
if (capped) {
|
||||||
|
showAlert('候选池只有 ' + pool.length + ' 人,已按 ' + n + ' 人抽取(可关闭「排除已抽中」或调整筛选扩大候选)', 'warn');
|
||||||
|
} else {
|
||||||
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
showAlert('已抽取 ' + n + ' 人' + tail + (n > 1 ? ',可点击右上角按钮下载白名单' : ''), 'info');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderResult() {
|
function renderResult() {
|
||||||
const tb = $('#drawBody');
|
const tb = $('#drawBody');
|
||||||
@@ -344,24 +476,27 @@
|
|||||||
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
tb.innerHTML = '<tr><td colspan="6" class="empty">尚未抽取</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = state.picked.map((m, i) =>
|
// 抽取结果表不显示登陆账号(仅导出白名单才含该列),故只渲染 5 个数据列 + 操作列
|
||||||
'<tr>' +
|
tb.innerHTML = state.picked.map((m, i) => {
|
||||||
|
return '<tr>' +
|
||||||
'<td class="num">' + (i + 1) + '</td>' +
|
'<td class="num">' + (i + 1) + '</td>' +
|
||||||
'<td class="sid">' + esc(m.sid) + '</td>' +
|
'<td class="sid">' + esc(m.sid) + '</td>' +
|
||||||
'<td class="name">' + esc(m.name) + '</td>' +
|
'<td class="name">' + esc(m.name) + '</td>' +
|
||||||
'<td>' + esc(m.cls || '') + '</td>' +
|
'<td>' + esc(m.cls || '') + '</td>' +
|
||||||
'<td>' + esc(m.grp || '') + '</td>' +
|
'<td>' + esc(m.grp || '') + '</td>' +
|
||||||
'<td><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
'<td class="act"><button class="btn sm danger" data-drop="' + i + '" title="移出结果并加入排除名单">排除</button></td>' +
|
||||||
'</tr>').join('');
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 历史记录 ================= */
|
/* ================= 历史记录 ================= */
|
||||||
function pushLog(picked, detail) {
|
function pushLog(picked, detail, mode) {
|
||||||
|
const modeTxt = ({ weight: '按人数加权', even: '均分', custom: '自定义比例', off: '不分组' })[mode] || '';
|
||||||
state.log.unshift({
|
state.log.unshift({
|
||||||
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
|
ts: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||||
source: state.source === 'project' ? '已提交人员' : '学号名单',
|
source: state.source === 'project' ? '已提交人员' : '学号名单',
|
||||||
range: rangeText(),
|
range: rangeText(),
|
||||||
balance: detail.join(' / '),
|
balance: (modeTxt ? modeTxt + ':' : '') + (detail || []).join(' / '),
|
||||||
count: picked.length,
|
count: picked.length,
|
||||||
members: picked.map(m => ({
|
members: picked.map(m => ({
|
||||||
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
|
sid: m.sid, name: m.name, cls: m.cls || '', grp: m.grp || '', login: m.login || '',
|
||||||
@@ -398,7 +533,7 @@
|
|||||||
'</span></div>' +
|
'</span></div>' +
|
||||||
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
'<details class="log-detail"><summary>查看成员(' + e.count + ')</summary>' +
|
||||||
'<div class="table-scroll short"><table class="grid compact">' +
|
'<div class="table-scroll short"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:56px">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th>班级</th><th>组别</th></tr></thead>' +
|
'<thead><tr><th style="width:8%;text-align:right">序号</th><th style="width:28%">学号</th><th style="width:26%">姓名</th><th style="width:24%">班级</th><th style="width:14%">组别</th></tr></thead>' +
|
||||||
'<tbody>' + rows + '</tbody></table></div>' +
|
'<tbody>' + rows + '</tbody></table></div>' +
|
||||||
'</details>' +
|
'</details>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -498,14 +633,24 @@
|
|||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
try {
|
try {
|
||||||
|
// 收集自定义比例输入
|
||||||
|
const cr = {};
|
||||||
|
$$('#ratioBox input[data-gk]').forEach(inp => {
|
||||||
|
const k = inp.dataset.gk;
|
||||||
|
const v = parseFloat(inp.value);
|
||||||
|
if (k && Number.isFinite(v) && v > 0) cr[k] = v;
|
||||||
|
});
|
||||||
|
state.customRatios = cr;
|
||||||
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
||||||
source: state.source,
|
source: state.source,
|
||||||
projectId: $('#projectSel').value,
|
projectId: $('#projectSel').value,
|
||||||
cls: $('#clsSel').value,
|
cls: $('#clsSel').value,
|
||||||
grp: $('#grpSel').value,
|
grp: $('#grpSel').value,
|
||||||
num: $('#numInput').value,
|
num: $('#numInput').value,
|
||||||
|
all: state.all,
|
||||||
exclude: $('#excludePicked').checked,
|
exclude: $('#excludePicked').checked,
|
||||||
balance: $('#balanceChk').checked,
|
mode: $('#balanceMode').value,
|
||||||
|
customRatios: cr,
|
||||||
}));
|
}));
|
||||||
} catch (e) { /* 忽略 */ }
|
} catch (e) { /* 忽略 */ }
|
||||||
}
|
}
|
||||||
@@ -548,13 +693,14 @@
|
|||||||
}
|
}
|
||||||
populateFilters();
|
populateFilters();
|
||||||
renderStats();
|
renderStats();
|
||||||
|
renderRatioBox();
|
||||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||||
saveSettings();
|
saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#projectSel').addEventListener('change', async () => {
|
$('#projectSel').addEventListener('change', async () => {
|
||||||
const pid = $('#projectSel').value;
|
const pid = $('#projectSel').value;
|
||||||
if (!pid) { state.answers = []; populateFilters(); renderStats(); return; }
|
if (!pid) { state.answers = []; populateFilters(); renderStats(); renderRatioBox(); return; }
|
||||||
showAlert('正在拉取已提交人员…', 'info');
|
showAlert('正在拉取已提交人员…', 'info');
|
||||||
try {
|
try {
|
||||||
await loadAnswers(pid);
|
await loadAnswers(pid);
|
||||||
@@ -564,24 +710,41 @@
|
|||||||
}
|
}
|
||||||
populateFilters();
|
populateFilters();
|
||||||
renderStats();
|
renderStats();
|
||||||
|
renderRatioBox();
|
||||||
if ($('#exclModal').classList.contains('open')) renderExcl();
|
if ($('#exclModal').classList.contains('open')) renderExcl();
|
||||||
saveSettings();
|
saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
['#clsSel', '#grpSel'].forEach(id => {
|
['#clsSel', '#grpSel'].forEach(id => {
|
||||||
$(id).addEventListener('change', () => { renderStats(); saveSettings(); });
|
$(id).addEventListener('change', () => {
|
||||||
|
renderStats(); renderRatioBox(); saveSettings();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
$('#numInput').addEventListener('change', saveSettings);
|
$('#numInput').addEventListener('change', saveSettings);
|
||||||
|
// 手工改人数即解除「全部」模式
|
||||||
|
$('#numInput').addEventListener('input', () => { state.all = false; });
|
||||||
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
$('#excludePicked').addEventListener('change', () => { renderStats(); saveSettings(); });
|
||||||
$('#balanceChk').addEventListener('change', saveSettings);
|
$('#balanceMode').addEventListener('change', () => {
|
||||||
|
renderRatioBox();
|
||||||
|
saveSettings();
|
||||||
|
});
|
||||||
|
// 班级/组别/来源切换会改变候选池 → 比例输入框也要重画
|
||||||
|
['#sourceSel', '#projectSel', '#clsSel', '#grpSel'].forEach(id => {
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.addEventListener('change', () => {
|
||||||
|
// 延迟一帧,等 populateFilters 填好再算
|
||||||
|
setTimeout(renderRatioBox, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$$('[data-num]').forEach(b => {
|
$$('[data-num]').forEach(b => {
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
const v = b.dataset.num;
|
const v = b.dataset.num;
|
||||||
if (v === 'all') {
|
if (v === 'all') {
|
||||||
const pool = currentPool();
|
state.all = true;
|
||||||
$('#numInput').value = String(Math.max(1, pool.length));
|
$('#numInput').value = String(Math.max(1, currentPool().length));
|
||||||
} else {
|
} else {
|
||||||
|
state.all = false;
|
||||||
$('#numInput').value = v;
|
$('#numInput').value = v;
|
||||||
}
|
}
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -592,6 +755,9 @@
|
|||||||
|
|
||||||
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
|
// 重置结果:清空本次结果 + 累计已抽取(历史记录保留)
|
||||||
$('#btnReset').addEventListener('click', () => {
|
$('#btnReset').addEventListener('click', () => {
|
||||||
|
if (state.picked.length || state.history.size) {
|
||||||
|
if (!confirm('确定重置?将清空本次结果与累计已抽取的 ' + state.history.size + ' 人(历史记录仍保留)。')) return;
|
||||||
|
}
|
||||||
state.picked = [];
|
state.picked = [];
|
||||||
state.history = new Set();
|
state.history = new Set();
|
||||||
renderResult();
|
renderResult();
|
||||||
@@ -603,6 +769,37 @@
|
|||||||
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
|
showAlert('已重置结果与累计已抽取(历史记录仍保留)', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 复制名单:两列「姓名 / 登陆账号」,制表符分隔,便于直接粘贴到 Excel / 微信
|
||||||
|
$('#btnCopy').addEventListener('click', async () => {
|
||||||
|
if (!state.picked.length) {
|
||||||
|
showAlert('请先点击「🎲 开始抽取」再复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = state.picked.map(m =>
|
||||||
|
(m.name || '') + '\t' + (m.login || m.sid || ''));
|
||||||
|
const text = lines.join('\n');
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (e) { /* 降级到 textarea */ }
|
||||||
|
if (!ok) {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
} catch (e) { ok = false; }
|
||||||
|
}
|
||||||
|
showAlert(ok ? ('已复制 ' + state.picked.length + ' 人的姓名 / 登陆账号到剪贴板') : '复制失败,请手动选择结果复制', ok ? 'info' : '');
|
||||||
|
});
|
||||||
|
|
||||||
// 清空历史(顶栏)
|
// 清空历史(顶栏)
|
||||||
$('#btnClearLog').addEventListener('click', () => {
|
$('#btnClearLog').addEventListener('click', () => {
|
||||||
if (!state.log.length) return showAlert('暂无历史记录');
|
if (!state.log.length) return showAlert('暂无历史记录');
|
||||||
@@ -692,11 +889,55 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* --- 弹窗通用关闭 --- */
|
/* --- 弹窗通用关闭:只关最上层那一个,避免连带关掉下层的弹窗 --- */
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (e.target.matches('[data-close]') || e.target.classList.contains('modal')) {
|
const isClose = (e.target.matches && e.target.matches('[data-close]')) ||
|
||||||
$$('.modal').forEach(m => m.classList.remove('open'));
|
(e.target.classList && e.target.classList.contains('modal'));
|
||||||
|
if (!isClose) return;
|
||||||
|
const open = $$('.modal.open');
|
||||||
|
if (!open.length) return;
|
||||||
|
const top = open[open.length - 1];
|
||||||
|
// 点遮罩时只响应最上层弹窗自身的遮罩
|
||||||
|
if (e.target.classList && e.target.classList.contains('modal') && e.target !== top) return;
|
||||||
|
top.classList.remove('open');
|
||||||
|
});
|
||||||
|
/* Esc 关闭最上层弹窗 */
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||||
|
const open = $$('.modal.open');
|
||||||
|
if (open.length) open[open.length - 1].classList.remove('open');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染"自定义比例"输入框:按当前候选池的分组(组别/班级)列出,每个组一个数字输入框,
|
||||||
|
* 默认按各组人数预填(即等价"按人数加权"),用户可改。
|
||||||
|
*/
|
||||||
|
function renderRatioBox() {
|
||||||
|
const box = $('#ratioBox');
|
||||||
|
if (!box) return;
|
||||||
|
const pool = currentPool();
|
||||||
|
const g = poolGroups(pool);
|
||||||
|
const mode = $('#balanceMode').value;
|
||||||
|
const show = (mode === 'custom') && g.key;
|
||||||
|
box.classList.toggle('hidden', !show);
|
||||||
|
// 隐藏时必须清空:否则旧的 data-gk 输入框还会被 saveSettings 读走,
|
||||||
|
// 下次切成自定义比例时会误用上一批分组的权重
|
||||||
|
if (!show) { box.innerHTML = ''; return; }
|
||||||
|
box.innerHTML =
|
||||||
|
'<p class="hint" style="margin:0 0 6px">按上面分组的顺序填写权重,例:组A=3,组B=1。留空=不计该组。</p>' +
|
||||||
|
g.gk.map((k, i) => {
|
||||||
|
const def = (state.customRatios && Number.isFinite(parseFloat(state.customRatios[k])))
|
||||||
|
? state.customRatios[k]
|
||||||
|
: g.sizes[i];
|
||||||
|
return '<div class="row" style="display:flex;gap:8px;align-items:center;margin:4px 0">' +
|
||||||
|
'<label style="min-width:120px">' + esc(k) + ' <small style="color:var(--text-3)">(' + g.sizes[i] + '人)</small></label>' +
|
||||||
|
'<input type="number" min="0" step="0.1" data-gk="' + esc(k) + '" value="' + esc(def) + '" placeholder="' + g.sizes[i] + '" style="width:120px">' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
// 改动即时保存
|
||||||
|
box.querySelectorAll('input[data-gk]').forEach(inp => {
|
||||||
|
inp.addEventListener('change', saveSettings);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,17 +946,25 @@
|
|||||||
loadState();
|
loadState();
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
if (s.exclude === false) $('#excludePicked').checked = false;
|
if (s.exclude === false) $('#excludePicked').checked = false;
|
||||||
if (s.balance === false) $('#balanceChk').checked = false;
|
// 兼容旧版:s.balance === false → 不分组
|
||||||
|
const mode = s.mode || (s.balance === false ? 'off' : 'weight');
|
||||||
|
$('#balanceMode').value = mode;
|
||||||
if (s.num) $('#numInput').value = s.num;
|
if (s.num) $('#numInput').value = s.num;
|
||||||
|
state.all = !!s.all;
|
||||||
if (s.source === 'project') {
|
if (s.source === 'project') {
|
||||||
state.source = 'project';
|
state.source = 'project';
|
||||||
$('#sourceSel').value = 'project';
|
$('#sourceSel').value = 'project';
|
||||||
$('#projectField').classList.remove('hidden');
|
$('#projectField').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
if (s.customRatios && typeof s.customRatios === 'object') {
|
||||||
|
state.customRatios = s.customRatios;
|
||||||
|
}
|
||||||
|
|
||||||
bind();
|
bind();
|
||||||
renderResult();
|
renderResult();
|
||||||
renderStats();
|
renderStats();
|
||||||
|
// 第一次也要按当前池渲染自定义比例框
|
||||||
|
renderRatioBox();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadRoster();
|
await loadRoster();
|
||||||
|
|||||||
@@ -6,14 +6,22 @@
|
|||||||
--text: #1d2433;
|
--text: #1d2433;
|
||||||
--text-2: #5b6577;
|
--text-2: #5b6577;
|
||||||
--text-3: #8b95a7;
|
--text-3: #8b95a7;
|
||||||
|
/* 主色阶:--primary 基准、--primary-2 悬停/激活深一档、--primary-3 浅色渐变端 */
|
||||||
--primary: #2f6bff;
|
--primary: #2f6bff;
|
||||||
|
--primary-2: #2559db;
|
||||||
|
--primary-3: #5b8bff;
|
||||||
--primary-weak: #eaf0ff;
|
--primary-weak: #eaf0ff;
|
||||||
|
--brand-2: #6a4bff; /* 品牌渐变收尾(紫)—— logo/面板条/登录条 */
|
||||||
--ok: #12a86a;
|
--ok: #12a86a;
|
||||||
|
--ok-2: #3ec99a; /* 成功渐变亮端(toast) */
|
||||||
--ok-weak: #e7f7ef;
|
--ok-weak: #e7f7ef;
|
||||||
--warn: #e08c1a;
|
--warn: #e08c1a;
|
||||||
|
--warn-2: #f0a54a; /* 警告渐变亮端(toast) */
|
||||||
--warn-weak: #fdf3e2;
|
--warn-weak: #fdf3e2;
|
||||||
--danger: #e0483d;
|
--danger: #e0483d;
|
||||||
|
--danger-2: #ef6c64; /* 危险渐变亮端(toast) */
|
||||||
--danger-weak: #fdecea;
|
--danger-weak: #fdecea;
|
||||||
|
--ink: #1b2440; /* 登录页深底 */
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
--shadow: 0 1px 2px rgba(20, 30, 60, .05), 0 8px 24px rgba(20, 30, 60, .06);
|
||||||
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
--shadow-lg: 0 12px 32px rgba(20, 30, 60, .12);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
@@ -53,7 +61,12 @@ body {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
box-shadow: 0 2px 12px rgba(20, 30, 60, .05);
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 12px; }
|
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
/* 导航栏主标题(已移除 Logo 与副标题,只保留单一主标题) */
|
||||||
|
.brand-title {
|
||||||
|
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: .3px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.logo {
|
.logo {
|
||||||
width: 38px; height: 38px;
|
width: 38px; height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -80,6 +93,8 @@ body {
|
|||||||
.conn-err { background: var(--danger-weak); color: var(--danger); }
|
.conn-err { background: var(--danger-weak); color: var(--danger); }
|
||||||
.conn-err i { background: var(--danger); }
|
.conn-err i { background: var(--danger); }
|
||||||
.conn-load i { background: var(--warn); animation: blink 1s infinite; }
|
.conn-load i { background: var(--warn); animation: blink 1s infinite; }
|
||||||
|
.conn-warn { background: var(--warn-weak, #fff4e0); color: var(--warn, #e08c1a); }
|
||||||
|
.conn-warn i { background: var(--warn, #e08c1a); animation: blink 1s infinite; }
|
||||||
@keyframes blink { 50% { opacity: .25; } }
|
@keyframes blink { 50% { opacity: .25; } }
|
||||||
|
|
||||||
/* ---------- 按钮 ---------- */
|
/* ---------- 按钮 ---------- */
|
||||||
@@ -113,8 +128,9 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
.btn-primary:hover { background: #2559db; border-color: #2559db; }
|
.btn-primary:hover { background: var(--primary-2); border-color: var(--primary-2); }
|
||||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.btn.xs { padding: 3px 8px; font-size: 11px; }
|
||||||
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
.btn.danger { color: var(--danger); border-color: #f3cfcb; }
|
||||||
.btn.danger:hover { background: var(--danger-weak); }
|
.btn.danger:hover { background: var(--danger-weak); }
|
||||||
.btn b {
|
.btn b {
|
||||||
@@ -154,6 +170,24 @@ select, input[type=text], input[type=number], input[type=password] {
|
|||||||
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
|
select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,107,255,.12); }
|
||||||
.field.btns select { width: 100px; }
|
.field.btns select { width: 100px; }
|
||||||
|
|
||||||
|
/* ---------- 副工具栏:导出等次要操作独立成行,避免挤在主工具栏里 ---------- */
|
||||||
|
.subbar {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
|
||||||
|
margin-top: 10px; padding: 10px 14px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.subbar-label {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .5px;
|
||||||
|
padding-right: 12px; border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.subbar-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subbar-item input[type=checkbox] { width: auto; height: auto; margin: 0; cursor: pointer; }
|
||||||
|
.subbar-item select { width: auto; min-width: 112px; height: 30px; padding: 0 8px; font-size: 12px; }
|
||||||
|
|
||||||
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
|
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.switch span {
|
.switch span {
|
||||||
@@ -231,10 +265,9 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
|
.updated { font-size: 12px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
|
||||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd3e2; display: inline-block; }
|
||||||
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
|
.dot.on { background: var(--ok); animation: pulse 1.6s infinite; }
|
||||||
|
.dot.stale { background: var(--danger); animation: blink 1s infinite; }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
/* ---------- 三 panel 布局容器(默认 / 成绩明细与缺考名单同行) ---------- */
|
||||||
.layout {
|
.layout {
|
||||||
@@ -269,40 +302,86 @@ select:focus, input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 表格 ---------- */
|
/* ---------- 表格 ---------- */
|
||||||
.table-scroll { overflow: auto; max-height: 62vh; }
|
.table-scroll { overflow: auto; max-height: 62vh; scrollbar-gutter: stable; }
|
||||||
.table-scroll.short { max-height: 320px; }
|
.table-scroll.short { max-height: 320px; }
|
||||||
.table-scroll.mid { max-height: 380px; }
|
.table-scroll.mid { max-height: 380px; }
|
||||||
|
|
||||||
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; table-layout: fixed; }
|
||||||
table.grid th {
|
table.grid th {
|
||||||
position: sticky; top: 0; z-index: 2;
|
position: sticky; top: 0; z-index: 2;
|
||||||
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
background: linear-gradient(180deg, #fbfcfe, #f4f7fc); color: var(--text-2);
|
||||||
text-align: left; font-weight: 600; font-size: 12px;
|
text-align: left; font-weight: 600; font-size: 12px;
|
||||||
padding: 11px 14px; border-bottom: 1px solid var(--border);
|
padding: 11px 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
/* 用 box-shadow 画分隔线而非 border:border 在 border-collapse:separate + sticky 下
|
||||||
|
不会与下方 td 边框产生折叠重绘缝隙(消除吸顶表头在滚动边界的 1px 双线/错位观感) */
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: inset 0 -1px 0 var(--border);
|
box-shadow: inset 0 -1px 0 var(--border);
|
||||||
}
|
}
|
||||||
table.grid th.sortable { cursor: pointer; user-select: none; }
|
table.grid th.sortable { cursor: pointer; user-select: none; }
|
||||||
table.grid th.sortable:hover { color: var(--primary); }
|
table.grid th.sortable:hover { color: var(--primary); }
|
||||||
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
table.grid th .arrow { color: var(--primary); margin-left: 3px; }
|
||||||
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); }
|
table.grid td { padding: 10px 14px; border-bottom: 1px solid var(--border-2); vertical-align: middle; background-clip: padding-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* 含按钮的操作列:不能被 ellipsis 的 overflow:hidden 裁掉按钮 */
|
||||||
|
table.grid td.act { overflow: visible; text-overflow: clip; }
|
||||||
|
/* 虚拟滚动占位行:不占视觉空间、不响应 hover/条纹、不显示边框 */
|
||||||
|
table.grid tbody tr.vspacer td { padding: 0; border: 0; background: transparent !important; }
|
||||||
|
table.grid tbody tr.vspacer:nth-child(even) td { background: transparent !important; }
|
||||||
|
table.grid tbody tr.vspacer:hover td { background: transparent !important; }
|
||||||
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
table.grid tbody tr:nth-child(even) td { background: #fbfcff; }
|
||||||
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
table.grid tbody tr:hover td { background: var(--primary-weak); }
|
||||||
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
table.grid.compact td, table.grid.compact th { padding: 7px 12px; }
|
||||||
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
td.empty { text-align: center; color: var(--text-3); padding: 32px 0; }
|
||||||
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
td.sid { font-weight: 600; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||||
|
/* 数值列右对齐:成绩 / 次数 / 时长纵向扫读时更容易对齐比对 */
|
||||||
|
table.grid td.num, table.grid td.score { text-align: right; }
|
||||||
|
/* 表头对齐 = data-k 派生,与单元格的正则保持一致;避免"表头左对齐、数据右对齐"造成的视觉错列 */
|
||||||
|
table.grid thead th[data-k="score"],
|
||||||
|
table.grid thead th[data-k="attempts"],
|
||||||
|
table.grid thead th[data-k="submit_ts"],
|
||||||
|
table.grid thead th[data-k="duration"] { text-align: right; }
|
||||||
|
table.grid td.score { font-weight: 700; font-size: 14px; }
|
||||||
|
table.grid td.name { font-weight: 600; }
|
||||||
td.name { font-weight: 600; }
|
td.name { font-weight: 600; }
|
||||||
td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
td.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
.score-high { color: var(--ok); }
|
.score-high { color: var(--ok); }
|
||||||
.score-mid { color: var(--warn); }
|
.score-mid { color: var(--warn); }
|
||||||
.score-low { color: var(--danger); }
|
.score-low { color: var(--danger); }
|
||||||
.diff { animation: flash 1.6s ease-out; }
|
|
||||||
@keyframes flash { from { background: #fff6d9; } to { background: transparent; } }
|
|
||||||
|
|
||||||
/* 新增数据绿色底纹(每次刷新后标记,下一轮刷新前持续显示) */
|
/* 新数据行动画:FLIP 滑动(行移动) + 全新行落入;尊重 prefers-reduced-motion */
|
||||||
|
@keyframes rowDrop { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
table.grid tbody tr.row-drop-in { animation: rowDrop .42s ease; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
table.grid tbody tr.row-drop-in { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Toast 顶部飘窗通知 ---------- */
|
||||||
|
.toast {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-3));
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.toast-ok { background: linear-gradient(135deg, var(--ok), var(--ok-2)); color: #fff; }
|
||||||
|
.toast-error{ background: linear-gradient(135deg, var(--danger), var(--danger-2)); color: #fff; }
|
||||||
|
.toast-warn { background: linear-gradient(135deg, var(--warn), var(--warn-2)); color: #fff; }
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes toastOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(-8px) scale(.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新数据绿色底纹:常亮(不再闪烁),由"清除高亮"或切换项目/姓名题清除 */
|
||||||
table.grid tbody tr.new td { background: #e7f7ee; }
|
table.grid tbody tr.new td { background: #e7f7ee; }
|
||||||
|
table.grid tbody tr.new:nth-child(even) td { background: #e7f7ee; }
|
||||||
table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
||||||
|
/* 左侧色条,进一步强调"这条是新的" */
|
||||||
|
table.grid tbody tr.new td:first-child { box-shadow: inset 3px 0 0 var(--ok); }
|
||||||
|
|
||||||
|
/* 考试次数列 */
|
||||||
|
td.att { font-weight: 700; color: var(--text-2); }
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block; padding: 1px 8px; border-radius: 999px;
|
display: inline-block; padding: 1px 8px; border-radius: 999px;
|
||||||
@@ -311,6 +390,21 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
.tag-ok { background: var(--ok-weak); color: var(--ok); }
|
.tag-ok { background: var(--ok-weak); color: var(--ok); }
|
||||||
.tag-warn { background: var(--warn-weak); color: var(--warn); }
|
.tag-warn { background: var(--warn-weak); color: var(--warn); }
|
||||||
.tag-gray { background: var(--border-2); color: var(--text-3); }
|
.tag-gray { background: var(--border-2); color: var(--text-3); }
|
||||||
|
.tag-att { background: #e8eefc; color: #3457c9; cursor: pointer; }
|
||||||
|
.tag-new { background: var(--ok-weak); color: var(--ok); }
|
||||||
|
.tag-best { background: #fff1dc; color: #b26a06; }
|
||||||
|
/* 当日最高(历史成绩按天分组后,组内最高分) */
|
||||||
|
.tag-day { background: var(--primary-weak); color: var(--primary-2); }
|
||||||
|
/* 历史成绩弹窗:按提交日期分组的分组行 */
|
||||||
|
tr.att-day > td { background: var(--border-2); font-weight: 600; color: var(--text-2); }
|
||||||
|
tr.att-day > td .att-day-sub { margin-left: 8px; font-weight: 400; font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* 多次刷题:成员榜可点击行 */
|
||||||
|
tr.clickable { cursor: pointer; }
|
||||||
|
tr.clickable:hover td { background: var(--primary-weak); }
|
||||||
|
/* 绿色常亮行 hover 时保持绿色调,避免看起来"消失" */
|
||||||
|
table.grid tbody tr.new.clickable:hover td { background: #d6f2e2; }
|
||||||
|
.muted { color: var(--text-3); }
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
.modal {
|
.modal {
|
||||||
@@ -334,6 +428,81 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-3); }
|
||||||
.x:hover { color: var(--text); }
|
.x:hover { color: var(--text); }
|
||||||
.modal-body { padding: 18px; overflow: auto; }
|
.modal-body { padding: 18px; overflow: auto; }
|
||||||
|
/* 弹窗内的表格不做吸顶:弹窗自身是滚动容器,吸顶表头会与内容错层(且贴边观感差) */
|
||||||
|
.modal-body table.grid th { position: static; }
|
||||||
|
.modal-body table.grid { box-shadow: inset 0 0 0 1px var(--border-2); border-radius: 8px; }
|
||||||
|
/* ================= 统一导出 / 打印弹窗 ================= */
|
||||||
|
.ex-sec {
|
||||||
|
padding: 12px 14px; margin: 0 0 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; background: #fbfcfe;
|
||||||
|
}
|
||||||
|
.ex-sec[hidden] { display: none; }
|
||||||
|
.ex-sec-title {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text-2);
|
||||||
|
letter-spacing: .3px; margin: 0 0 10px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.ex-sec-title small { font-weight: 500; color: var(--text-3, #8b93a1); }
|
||||||
|
|
||||||
|
/* 导出内容:2×2 卡片单选,一眼看清四种导出各是什么 */
|
||||||
|
.ex-types { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.ex-type {
|
||||||
|
display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; background: #fff;
|
||||||
|
cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s;
|
||||||
|
}
|
||||||
|
.ex-type:hover { border-color: #b9c2d0; }
|
||||||
|
.ex-type:has(input:checked) {
|
||||||
|
border-color: var(--primary, #2f6fed); background: #f3f7ff;
|
||||||
|
box-shadow: 0 0 0 1px var(--primary, #2f6fed) inset;
|
||||||
|
}
|
||||||
|
.ex-type input { margin-top: 3px; flex: none; }
|
||||||
|
.ex-type-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||||
|
.ex-type-t b { font-size: 13px; color: var(--text); }
|
||||||
|
.ex-type-t small { font-size: 11.5px; color: var(--text-3, #8b93a1); line-height: 1.45; }
|
||||||
|
|
||||||
|
/* 字段行 */
|
||||||
|
.ex-row { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: flex-start; }
|
||||||
|
.ex-field {
|
||||||
|
display: flex; flex-direction: column; gap: 6px; min-width: 150px;
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.ex-field select, .ex-field input[type=text], .ex-field input[type=number] {
|
||||||
|
font-size: 13px; font-weight: 400; color: var(--text);
|
||||||
|
}
|
||||||
|
.ex-field-tip {
|
||||||
|
min-width: 200px; flex: 1 1 240px; font-weight: 400;
|
||||||
|
display: flex; flex-direction: column; gap: 4px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ex-field-grow { flex: 1 1 240px; }
|
||||||
|
.ex-field-grow input { width: 100%; }
|
||||||
|
|
||||||
|
/* 班级 / 组别多选:竖排标签 + 列表,便于 Ctrl 多选 */
|
||||||
|
#exClsSel, #exGrpSel {
|
||||||
|
width: 168px; height: 96px; padding: 4px 6px; font-size: 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
background: #fff; color: var(--text); outline: none;
|
||||||
|
}
|
||||||
|
#exGrpSel { width: 132px; }
|
||||||
|
|
||||||
|
.ex-checks {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px;
|
||||||
|
font-size: 12.5px; color: var(--text);
|
||||||
|
}
|
||||||
|
.ex-checks label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 多考试:勾选列表 */
|
||||||
|
.ex-pick { margin-top: 12px; }
|
||||||
|
.ex-pick-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 0 0 8px;
|
||||||
|
font-size: 12.5px; font-weight: 600; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.ex-pick-head small { font-weight: 500; color: var(--text-3, #8b93a1); }
|
||||||
|
.table-scroll.mid { max-height: 208px; overflow: auto; }
|
||||||
|
|
||||||
|
/* 合规的旧类名保留(其它地方仍在用 .me-bar 之类的排版习惯) */
|
||||||
|
.me-bar { display: flex; align-items: center; gap: 16px; margin: 0 0 10px; }
|
||||||
|
.me-bar .chk { margin: 0; white-space: nowrap; }
|
||||||
.modal-foot {
|
.modal-foot {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
padding: 12px 18px; border-top: 1px solid var(--border); background: #fbfcfe;
|
||||||
@@ -345,13 +514,9 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
.fi { display: flex; flex-direction: column; gap: 6px; }
|
.fi { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.fi.full { grid-column: 1 / -1; }
|
.fi.full { grid-column: 1 / -1; }
|
||||||
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
.fi label { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
||||||
|
.fi label .req { color: var(--danger, #e5484d); margin-left: 2px; }
|
||||||
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
.fi-inline { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||||
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi-inline .chk { font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
.fullscores { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.fullscores .row { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.fullscores label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.fullscores input { width: 90px; flex: none; }
|
|
||||||
.fullscores .empty { padding: 6px 0; color: var(--text-3); font-size: 12px; text-align: left; }
|
|
||||||
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
.hint { margin: 0; font-size: 11px; color: var(--text-3); }
|
||||||
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
.fi.checks { flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
||||||
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
.fi.checks label { display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 13px; color: var(--text); }
|
||||||
@@ -361,6 +526,7 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
|
background: var(--ok-weak); color: #0d7a4d; border: 1px solid #c8ecda;
|
||||||
}
|
}
|
||||||
.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; }
|
.ping-result.err { background: var(--danger-weak); color: #a3342c; border-color: #f5d3cf; }
|
||||||
|
.ping-result.loading { background: var(--border-2); color: var(--text-2); border-color: var(--border-2); }
|
||||||
|
|
||||||
/* ---------- 名单 ---------- */
|
/* ---------- 名单 ---------- */
|
||||||
.upload-zone {
|
.upload-zone {
|
||||||
@@ -414,9 +580,6 @@ table.grid tbody tr.new:hover td { background: #d6f2e2; }
|
|||||||
.login-box .fi { margin-bottom: 14px; }
|
.login-box .fi { margin-bottom: 14px; }
|
||||||
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
.login-actions { display: flex; gap: 10px; margin-top: 6px; }
|
||||||
.login-actions .btn { flex: 1; }
|
.login-actions .btn { flex: 1; }
|
||||||
.login-msg { margin-top: 12px; font-size: 12px; min-height: 18px; white-space: pre-wrap; }
|
|
||||||
.login-msg.ok { color: var(--ok); }
|
|
||||||
.login-msg.err { color: var(--danger); }
|
|
||||||
|
|
||||||
/* ---------- 全屏适配 ---------- */
|
/* ---------- 全屏适配 ---------- */
|
||||||
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
body.fs-on .wrap { max-width: none; padding-top: 8px; }
|
||||||
@@ -427,32 +590,44 @@ body.fs-on .card b { font-size: 28px; }
|
|||||||
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
||||||
|
|
||||||
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
|
/* ---------- 三端自适应(桌面 / 平板 / 手机) ---------- */
|
||||||
|
|
||||||
|
/* 大屏笔记本:略收边距,统计卡片保持一行 */
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.wrap { padding: 16px 16px 40px; }
|
.wrap { padding: 16px 16px 40px; }
|
||||||
.topbar { padding: 10px 16px; }
|
.topbar { padding: 10px 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平板横屏(769~1024):卡片改 4 列、工具栏字段占满整行 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
|
||||||
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
.field.grow, .field.grow-2 { flex: 1 1 100%; min-width: 0; }
|
||||||
|
.cards { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.toolbar { gap: 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平板竖屏 / 大手机(<=768):整体收紧,工具栏与按钮自动换行 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body { font-size: 13px; }
|
body { font-size: 13px; }
|
||||||
.topbar { flex-wrap: wrap; gap: 10px; }
|
.topbar { flex-wrap: wrap; gap: 10px; }
|
||||||
.brand-text small { display: none; }
|
.brand { flex: 1 1 100%; }
|
||||||
|
.brand-title { font-size: 15px; }
|
||||||
.top-right { flex-wrap: wrap; gap: 8px; }
|
.top-right { flex-wrap: wrap; gap: 8px; }
|
||||||
.top-right .btn { padding: 6px 10px; font-size: 12px; }
|
.top-right .btn { padding: 6px 10px; font-size: 12px; }
|
||||||
.btn b { font-size: 10px; }
|
.btn b { font-size: 10px; }
|
||||||
.wrap { padding: 12px 12px 36px; }
|
.wrap { padding: 12px 12px 36px; }
|
||||||
.toolbar { padding: 12px; gap: 10px; }
|
.toolbar { padding: 12px; gap: 10px 12px; }
|
||||||
.field { flex: 1 1 100%; min-width: 0; }
|
.field { flex: 1 1 100%; min-width: 0; }
|
||||||
.field.btns { flex: 1 1 100%; }
|
.field.btns { flex: 1 1 100%; flex-wrap: wrap; row-gap: 8px; }
|
||||||
.field.btns select { width: 100%; }
|
.field.btns select { width: 100%; }
|
||||||
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.cards { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
.card { padding: 10px 12px; }
|
.card { padding: 10px 12px; }
|
||||||
.card b { font-size: 20px; }
|
.card b { font-size: 20px; }
|
||||||
.panel-head { flex-direction: column; align-items: flex-start; gap: 6px; }
|
.card span { font-size: 11px; }
|
||||||
|
/* 面板头部(含按钮)竖排,按钮自动换行 */
|
||||||
|
.panel-head { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
/* 竖排时 .spacer(flex:1)会把内容上下撑开,必须隐藏 */
|
||||||
|
.panel-head > .spacer { display: none; }
|
||||||
|
.panel-head .updated { flex-wrap: wrap; width: 100%; }
|
||||||
|
.panel-head .btn, .panel-head .updated .btn { flex: 0 0 auto; }
|
||||||
.table-scroll { max-height: 56vh; }
|
.table-scroll { max-height: 56vh; }
|
||||||
table.grid { font-size: 12px; }
|
table.grid { font-size: 12px; }
|
||||||
table.grid th { padding: 8px 10px; white-space: nowrap; }
|
table.grid th { padding: 8px 10px; white-space: nowrap; }
|
||||||
@@ -461,19 +636,36 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
|
.modal-box, .modal-box.wide { width: 100%; max-height: 92vh; }
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.modal-body { padding: 14px; }
|
.modal-body { padding: 14px; }
|
||||||
|
/* 弹窗底栏在小屏换行,按钮可点满宽 */
|
||||||
|
.modal-foot { flex-wrap: wrap; padding: 10px 14px; }
|
||||||
|
.modal-foot > .spacer { display: none; }
|
||||||
|
.modal-foot .btn { flex: 1 1 auto; }
|
||||||
|
.roster-bar { flex-wrap: wrap; }
|
||||||
.roster-bar .chk { font-size: 11px; }
|
.roster-bar .chk { font-size: 11px; }
|
||||||
|
/* 名单表在窄屏可横向滚动,避免 6 列挤爆 */
|
||||||
|
#rosterTable { min-width: 560px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机(<=480):卡片两列,进一步压缩字号与内边距 */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.cards { grid-template-columns: repeat(2, 1fr); }
|
.cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
.brand-text strong { font-size: 14px; }
|
.brand-title { font-size: 14px; }
|
||||||
.topbar .logo { width: 32px; height: 32px; font-size: 13px; }
|
|
||||||
.btn { padding: 6px 9px; font-size: 12px; }
|
.btn { padding: 6px 9px; font-size: 12px; }
|
||||||
.field label { font-size: 11px; }
|
.field label { font-size: 11px; }
|
||||||
.roster-bar { gap: 8px; }
|
.roster-bar { gap: 8px; }
|
||||||
.roster-bar input[type=text] { width: 120px; }
|
.roster-bar input[type=text] { width: 120px; }
|
||||||
.panel-head h2 { font-size: 14px; }
|
.panel-head h2 { font-size: 14px; }
|
||||||
#rosterBody input { font-size: 12px; }
|
#rosterBody input { font-size: 12px; }
|
||||||
|
.modal-box { max-height: 94vh; }
|
||||||
|
.modal-body { padding: 12px; }
|
||||||
|
/* 工具条按钮在小屏铺满,便于点按 */
|
||||||
|
.field.btns .btn { flex: 1 1 auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触摸设备:增大点按热区,去掉 hover 残留高亮 */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.btn { min-height: 34px; }
|
||||||
|
table.grid tbody tr:hover td { background: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 数据看板 ---------- */
|
/* ---------- 数据看板 ---------- */
|
||||||
@@ -483,28 +675,75 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.dash-hero .brand strong { color: #fff; font-size: 17px; }
|
.dash-hero .brand-title { color: #fff; font-size: 17px; }
|
||||||
.dash-hero .brand small { color: rgba(255, 255, 255, .72); }
|
|
||||||
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
.dash-hero a.back { color: #fff; opacity: .9; font-size: 13px; text-decoration: none; font-weight: 600; }
|
||||||
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
.dash-hero a.back:hover { opacity: 1; text-decoration: underline; }
|
||||||
.dash-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.dash-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.dash-controls select { width: 230px; height: 38px; }
|
.dash-controls select { width: 230px; height: 38px; }
|
||||||
|
/* 看板顶栏的「主系统 / 退出」用描边区分于同排的半透明操作按钮 */
|
||||||
|
.dash-controls .btn.back {
|
||||||
|
background: transparent; border-color: rgba(255, 255, 255, .45); font-weight: 600;
|
||||||
|
}
|
||||||
|
.dash-controls .btn.back:hover { background: rgba(255, 255, 255, .18); border-color: #fff; }
|
||||||
|
/* 窄屏:下拉与按钮换行铺开,避免 230px 固定宽把顶栏撑出横向滚动条 */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.dash-hero { padding: 14px 16px; }
|
||||||
|
.dash-controls { width: 100%; gap: 8px; }
|
||||||
|
.dash-controls select { width: auto; flex: 1 1 160px; min-width: 0; }
|
||||||
|
.dash-controls .btn { flex: 0 0 auto; }
|
||||||
|
}
|
||||||
.dash-controls .inline { color: #fff; }
|
.dash-controls .inline { color: #fff; }
|
||||||
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
|
.dash-controls .btn { background: rgba(255, 255, 255, .14); border-color: transparent; color: #fff; }
|
||||||
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
|
.dash-controls .btn:hover { background: rgba(255, 255, 255, .24); }
|
||||||
|
/* 单班大屏:顶栏只保留「试卷 / 自动刷新 / 全屏」,班级选择与「← 主系统」隐藏
|
||||||
|
(退出按钮本形态下不再渲染,这里不再靠 CSS 藏) */
|
||||||
|
body.dash-class #classSelWrap,
|
||||||
|
body.dash-class #linkIndex { display: none !important; }
|
||||||
|
/* 公开(免登录)班级大屏的角标 */
|
||||||
|
.pub-tag {
|
||||||
|
display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px;
|
||||||
|
background: rgba(255, 255, 255, .16); border: 1px solid rgba(255, 255, 255, .38); color: #fff;
|
||||||
|
}
|
||||||
|
/* 首页汇总卡(5 卡)在该形态下无意义 */
|
||||||
|
body.dash-home #boardSummary { display: none; }
|
||||||
|
.dash-hero .brand-cls {
|
||||||
|
display: inline-block; margin-left: 2px; padding: 2px 10px; border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, .18); font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 首页:班级入口卡片 ---------- */
|
||||||
|
.cls-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
||||||
|
gap: 14px; margin-top: 12px;
|
||||||
|
}
|
||||||
|
.cls-card {
|
||||||
|
min-width: 0; cursor: pointer; user-select: none;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 14px 16px 12px; box-shadow: var(--shadow);
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||||
|
}
|
||||||
|
.cls-card:hover { transform: translateY(-3px); border-color: var(--primary); box-shadow: var(--shadow-lg); }
|
||||||
|
.cls-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
.cls-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.cls-name { font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cls-go { flex: none; font-size: 12px; font-weight: 700; color: var(--primary); }
|
||||||
|
.cls-kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-top: 10px; }
|
||||||
|
.cls-kpi { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.cls-kpi span { font-size: 11px; color: var(--text-3); }
|
||||||
|
.cls-kpi b { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.cls-kpi.k-ok b { color: var(--ok); }
|
||||||
|
.cls-kpi.k-warn b { color: var(--warn); }
|
||||||
|
.cls-bar { height: 8px; border-radius: 999px; background: var(--border-2); overflow: hidden; margin-top: 12px; }
|
||||||
|
.cls-bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
||||||
|
.cls-sub {
|
||||||
|
margin-top: 7px; font-size: 11.5px; color: var(--text-3);
|
||||||
|
display: flex; justify-content: space-between; gap: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) { .cls-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
.sec-title { margin: 22px 0 4px; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||||||
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
.sec-title .tag-gray { background: var(--border-2); color: var(--text-3); padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
.class-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 14px; }
|
|
||||||
.class-card {
|
|
||||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 16px 18px; box-shadow: var(--shadow); transition: transform .18s, box-shadow .18s;
|
|
||||||
}
|
|
||||||
.class-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
|
||||||
.class-card h3 { margin: 0 0 2px; font-size: 16px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
|
||||||
.class-card .rate-num { font-size: 15px; font-weight: 800; white-space: nowrap; }
|
|
||||||
.class-card .sub { font-size: 12px; color: var(--text-3); margin-bottom: 12px; }
|
|
||||||
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
.bar { height: 9px; border-radius: 999px; background: var(--border-2); overflow: hidden; }
|
||||||
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
.bar > i { display: block; height: 100%; border-radius: 999px; transition: width .5s ease; }
|
||||||
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
.class-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-top: 14px; }
|
||||||
@@ -512,23 +751,10 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
.class-metrics .m span { color: var(--text-3); font-size: 11px; }
|
||||||
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
.class-metrics .m b { font-size: 17px; font-weight: 700; }
|
||||||
.metric-ok b { color: var(--ok); }
|
.metric-ok b { color: var(--ok); }
|
||||||
.metric-warn b { color: var(--warn); }
|
|
||||||
.metric-danger b { color: var(--danger); }
|
.metric-danger b { color: var(--danger); }
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
.empty { text-align: center; color: var(--text-3); padding: 40px 0; }
|
||||||
|
|
||||||
/* 班级卡片:可点击进入该班详情 */
|
|
||||||
.class-card.clickable { cursor: pointer; }
|
|
||||||
.class-card.clickable:hover { border-color: var(--primary); }
|
|
||||||
.grp-tag {
|
|
||||||
display: inline-block; margin-top: 12px; padding: 2px 10px; border-radius: 999px;
|
|
||||||
font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 班级详情头部 */
|
|
||||||
.detail-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
|
|
||||||
.detail-head h2 { margin: 0; font-size: 18px; font-weight: 700; }
|
|
||||||
|
|
||||||
/* 分组对比:A 在左、B 在右 */
|
/* 分组对比:A 在左、B 在右 */
|
||||||
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
.group-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||||
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
@media (max-width: 860px) { .group-compare { grid-template-columns: 1fr; } }
|
||||||
@@ -563,8 +789,8 @@ body.fs-on .topbar { padding-top: 8px; padding-bottom: 8px; }
|
|||||||
.rk-3 { background: #fbe9dd; color: #b45309; }
|
.rk-3 { background: #fbe9dd; color: #b45309; }
|
||||||
|
|
||||||
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
/* 防溢出:网格子项允许收缩,长内容不撑破页面 */
|
||||||
.class-grid, .group-compare, .cards, .gpanel, .class-card { min-width: 0; }
|
.group-compare, .cards, .gpanel { min-width: 0; }
|
||||||
.gpanel, .class-card { overflow: hidden; }
|
.gpanel { overflow: hidden; }
|
||||||
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.member-list td.name { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
/* 看板全屏:整页不溢出屏幕(外层锁 100vh + overflow:hidden,内部区域自行滚动) */
|
||||||
@@ -577,13 +803,12 @@ body.dash.fs-on .sec-title { margin: 12px 0 4px; font-size: 14px; }
|
|||||||
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
body.dash.fs-on .cards { gap: 10px; margin-top: 10px; }
|
||||||
body.dash.fs-on .card { padding: 10px 12px; }
|
body.dash.fs-on .card { padding: 10px 12px; }
|
||||||
body.dash.fs-on .card b { font-size: 20px; }
|
body.dash.fs-on .card b { font-size: 20px; }
|
||||||
body.dash.fs-on #overview, body.dash.fs-on #detail {
|
|
||||||
flex: 1 1 auto; min-height: 0; overflow: auto;
|
|
||||||
}
|
|
||||||
body.dash.fs-on .class-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
||||||
body.dash.fs-on .group-compare { gap: 12px; }
|
body.dash.fs-on .group-compare { gap: 12px; }
|
||||||
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
body.dash.fs-on .gpanel { padding: 12px 14px; }
|
||||||
body.dash.fs-on .member-list { max-height: 30vh; }
|
body.dash.fs-on .member-list { max-height: none; }
|
||||||
|
body.dash.fs-on .cls-grid { grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); gap: 10px; }
|
||||||
|
body.dash.fs-on .cls-card { padding: 10px 12px; }
|
||||||
|
body.dash.fs-on .cls-kpi b { font-size: 15px; }
|
||||||
|
|
||||||
|
|
||||||
/* ================= 随机抽取页 ================= */
|
/* ================= 随机抽取页 ================= */
|
||||||
@@ -611,6 +836,11 @@ body.dash.fs-on .member-list { max-height: 30vh; }
|
|||||||
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
|
background: linear-gradient(180deg, #f2fbf5, #e9f7ee);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
}
|
}
|
||||||
|
/* 只抽 1 人时的副行(学号 · 班级 · 组别),字号小于主名字 */
|
||||||
|
.draw-stage-sub {
|
||||||
|
margin-top: 8px; font-size: 14px; font-weight: 600;
|
||||||
|
color: var(--text-2); letter-spacing: 0;
|
||||||
|
}
|
||||||
@keyframes drawShake {
|
@keyframes drawShake {
|
||||||
from { transform: translateY(-1px) scale(.995); }
|
from { transform: translateY(-1px) scale(.995); }
|
||||||
to { transform: translateY(1px) scale(1.005); }
|
to { transform: translateY(1px) scale(1.005); }
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ const NY_APP_NAME = 'NY问卷考试系统数据平台';
|
|||||||
const NY_VERSION = '1.0.0';
|
const NY_VERSION = '1.0.0';
|
||||||
|
|
||||||
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
||||||
define('NY_DATA', NY_ROOT . '/data');
|
// 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
|
||||||
|
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
|
||||||
|
$nyData = NY_ROOT . '/data';
|
||||||
|
$nyDataEnv = getenv('NY_DATA');
|
||||||
|
if ($nyDataEnv !== false && $nyDataEnv !== '') {
|
||||||
|
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
|
||||||
|
}
|
||||||
|
define('NY_DATA', $nyData);
|
||||||
|
|
||||||
// ---------------- 环境预检 ----------------
|
// ---------------- 环境预检 ----------------
|
||||||
if (PHP_VERSION_ID < 80000) {
|
if (PHP_VERSION_ID < 80000) {
|
||||||
@@ -36,6 +43,30 @@ if ($missing !== []) {
|
|||||||
date_default_timezone_set('Asia/Shanghai');
|
date_default_timezone_set('Asia/Shanghai');
|
||||||
mb_internal_encoding('UTF-8');
|
mb_internal_encoding('UTF-8');
|
||||||
|
|
||||||
|
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
|
||||||
|
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
|
||||||
|
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
|
||||||
|
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
|
||||||
|
(function (): void {
|
||||||
|
$cur = (string) ini_get('memory_limit');
|
||||||
|
if ($cur === '' || $cur === '-1') {
|
||||||
|
return; // 未设置 / 已无限制,无需处理
|
||||||
|
}
|
||||||
|
$curBytes = (int) $cur;
|
||||||
|
$unit = strtoupper(substr($cur, -1));
|
||||||
|
if ($unit === 'G') {
|
||||||
|
$curBytes *= 1024 * 1024 * 1024;
|
||||||
|
} elseif ($unit === 'M') {
|
||||||
|
$curBytes *= 1024 * 1024;
|
||||||
|
} elseif ($unit === 'K') {
|
||||||
|
$curBytes *= 1024;
|
||||||
|
}
|
||||||
|
$want = 512 * 1024 * 1024;
|
||||||
|
if ($curBytes > 0 && $curBytes < $want) {
|
||||||
|
@ini_set('memory_limit', '512M');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// ---------------- 会话 ----------------
|
// ---------------- 会话 ----------------
|
||||||
function ny_session_start(): void
|
function ny_session_start(): void
|
||||||
{
|
{
|
||||||
@@ -80,17 +111,67 @@ function ny_logout(): void
|
|||||||
session_destroy();
|
session_destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 base_url 且账号非空 */
|
// ---------------- CSRF 防护 ----------------
|
||||||
|
/**
|
||||||
|
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
|
||||||
|
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
|
||||||
|
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
|
||||||
|
*/
|
||||||
|
function ny_csrf_token(): string
|
||||||
|
{
|
||||||
|
ny_session_start();
|
||||||
|
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
|
||||||
|
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
return $_SESSION['ny_csrf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取请求原始 body(仅读一次并缓存)。
|
||||||
|
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
|
||||||
|
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
|
||||||
|
*/
|
||||||
|
function ny_request_body(): string
|
||||||
|
{
|
||||||
|
if (!isset($GLOBALS['__ny_body'])) {
|
||||||
|
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
|
||||||
|
}
|
||||||
|
return $GLOBALS['__ny_body'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
||||||
|
function ny_csrf_ok(): bool
|
||||||
|
{
|
||||||
|
$given = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
|
||||||
|
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
|
||||||
|
} elseif (!empty($_POST['_csrf'])) {
|
||||||
|
$given = (string)$_POST['_csrf'];
|
||||||
|
} else {
|
||||||
|
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
||||||
|
$raw = ny_request_body();
|
||||||
|
if ($raw !== '') {
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
if (is_array($j) && isset($j['_csrf'])) {
|
||||||
|
$given = (string)$j['_csrf'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$token = ny_csrf_token();
|
||||||
|
return $given !== '' && hash_equals($token, $given);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 登录失败限流(已移除) ----------------
|
||||||
|
// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
|
||||||
|
// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
|
||||||
|
|
||||||
|
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
||||||
function ny_is_installed(): bool
|
function ny_is_installed(): bool
|
||||||
{
|
{
|
||||||
$cfg = ny_load_config();
|
$cfg = ny_load_config();
|
||||||
if (trim((string)($cfg['base_url'] ?? '')) === '') {
|
if (trim((string)($cfg['base_url'] ?? '')) === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 有无可用的后端(文件 config 存在 ,或 DB 连得上)
|
|
||||||
if (class_exists('Db') && Db::isConnected()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return is_file(ny_config_file());
|
return is_file(ny_config_file());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +181,14 @@ foreach (['', '/cache', '/uploads'] as $sub) {
|
|||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
@mkdir($dir, 0777, true);
|
@mkdir($dir, 0777, true);
|
||||||
}
|
}
|
||||||
}
|
// Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
|
||||||
// 保护数据目录(Apache)
|
if (!file_exists($dir . '/.htaccess')) {
|
||||||
if (!file_exists(NY_DATA . '/.htaccess')) {
|
@file_put_contents($dir . '/.htaccess', "Deny from all\n");
|
||||||
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
|
}
|
||||||
|
// 兜底:即使目录被索引也不暴露文件清单
|
||||||
|
if (!file_exists($dir . '/index.html')) {
|
||||||
|
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 默认配置 ----------------
|
// ---------------- 默认配置 ----------------
|
||||||
@@ -122,8 +207,18 @@ function ny_default_config(): array
|
|||||||
'dedupe' => true,
|
'dedupe' => true,
|
||||||
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
||||||
'full_score' => 100,
|
'full_score' => 100,
|
||||||
'full_scores' => [],
|
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
|
||||||
|
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
|
||||||
|
'pass_rate' => 60,
|
||||||
'layout' => 'default',
|
'layout' => 'default',
|
||||||
|
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
|
||||||
|
'history_display' => 'click',
|
||||||
|
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
|
||||||
|
'export_sort' => 'time',
|
||||||
|
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
|
||||||
|
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
|
||||||
|
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
|
||||||
|
'cache_ttl' => 15,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,24 +227,52 @@ function ny_config_file(): string
|
|||||||
return NY_DATA . '/config.json';
|
return NY_DATA . '/config.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置来源:db | file | default
|
||||||
|
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
|
||||||
|
*/
|
||||||
|
function ny_config_source(): string
|
||||||
|
{
|
||||||
|
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
|
||||||
|
}
|
||||||
|
|
||||||
function ny_load_config(): array
|
function ny_load_config(): array
|
||||||
{
|
{
|
||||||
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件
|
$GLOBALS['__ny_cfg_source'] = 'default';
|
||||||
$dbCfg = ny_db_config();
|
|
||||||
if ($dbCfg !== null) {
|
|
||||||
$cfg = ny_default_config();
|
|
||||||
foreach ($cfg as $k => $v) {
|
|
||||||
if (array_key_exists($k, $dbCfg)) {
|
|
||||||
$cfg[$k] = is_bool($v) ? (bool)$dbCfg[$k] : $dbCfg[$k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
|
||||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
|
||||||
return $cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = ny_default_config();
|
$cfg = ny_default_config();
|
||||||
$saved = ny_read_json(ny_config_file(), null);
|
$file = ny_config_file();
|
||||||
|
$saved = null;
|
||||||
|
|
||||||
|
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
$raw = Db::settingsGet('config', '');
|
||||||
|
if ($raw !== '') {
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
if (is_array($j)) {
|
||||||
|
$saved = $j;
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
|
||||||
|
if ($saved === null) {
|
||||||
|
$fromFile = ny_read_json($file, null);
|
||||||
|
if (is_array($fromFile)) {
|
||||||
|
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
|
||||||
|
$saved = $fromFile;
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) DB 不可用时退回文件
|
||||||
|
if ($saved === null) {
|
||||||
|
$saved = ny_read_json($file, null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($saved)) {
|
if (is_array($saved)) {
|
||||||
foreach ($cfg as $k => $v) {
|
foreach ($cfg as $k => $v) {
|
||||||
if (array_key_exists($k, $saved)) {
|
if (array_key_exists($k, $saved)) {
|
||||||
@@ -159,43 +282,251 @@ function ny_load_config(): array
|
|||||||
}
|
}
|
||||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||||
|
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
|
||||||
|
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
|
||||||
|
$cfg['password'] = (string)($cfg['password'] ?? '');
|
||||||
return $cfg;
|
return $cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ny_save_config(array $cfg): bool
|
function ny_save_config(array $cfg): bool
|
||||||
{
|
{
|
||||||
// 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件
|
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||||
if (class_exists('Db')) {
|
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
|
||||||
$db = Db::pdo();
|
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
|
||||||
if ($db !== null) {
|
$GLOBALS['__ny_cfg_save_error'] = '';
|
||||||
|
$notes = [];
|
||||||
|
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$ok = false;
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
try {
|
try {
|
||||||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
if (Db::settingsSet('config', $json)) {
|
||||||
return true;
|
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||||
|
$ok = true;
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// 落库失败则回退文件
|
$notes[] = '数据库写入异常:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||||
|
if ($fileOk) {
|
||||||
|
// 回读一致才算真成功
|
||||||
|
$back = ny_read_json(ny_config_file(), null);
|
||||||
|
if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
|
||||||
|
$fileOk = false;
|
||||||
|
$notes[] = '配置文件回读不一致:' . ny_config_file();
|
||||||
}
|
}
|
||||||
return ny_write_json(ny_config_file(), $cfg);
|
} else {
|
||||||
|
$notes[] = '配置文件写入失败:' . ny_config_file();
|
||||||
|
}
|
||||||
|
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
|
||||||
|
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
||||||
|
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||||
|
if ($fileOk && is_file(ny_config_file())) {
|
||||||
|
@chmod(ny_config_file(), 0600);
|
||||||
|
}
|
||||||
|
if (!$ok) {
|
||||||
|
$GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
|
||||||
|
}
|
||||||
|
if (!$ok && !$fileOk) {
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
|
||||||
|
} elseif ($notes !== []) {
|
||||||
|
// 有一处成功即不影响使用,仅记录降级信息
|
||||||
|
$GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
|
||||||
|
}
|
||||||
|
return $ok || $fileOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据库初始化后从 DB 读取配置(无则返回 null) */
|
/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
|
||||||
function ny_db_config(): ?array
|
function ny_config_save_error(): string
|
||||||
{
|
{
|
||||||
if (!class_exists('Db')) {
|
return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
|
||||||
|
* 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
|
||||||
|
*/
|
||||||
|
function ny_config_diag(): array
|
||||||
|
{
|
||||||
|
$dir = rtrim((string)NY_DATA, '/\\');
|
||||||
|
$file = ny_config_file();
|
||||||
|
$dbOk = class_exists('Db') && Db::pdo() !== null;
|
||||||
|
|
||||||
|
$dbWritable = false;
|
||||||
|
if ($dbOk) {
|
||||||
|
$probe = '__ny_probe_' . bin2hex(random_bytes(4));
|
||||||
|
$dbWritable = Db::settingsSet($probe, '1');
|
||||||
|
Db::settingsDelete($probe);
|
||||||
}
|
}
|
||||||
$db = Db::pdo();
|
|
||||||
if ($db === null) {
|
$cfgWritable = false;
|
||||||
return null;
|
$probeFile = $dir . '/.__ny_write_probe';
|
||||||
|
$fp = @fopen($probeFile, 'wb');
|
||||||
|
if ($fp) {
|
||||||
|
$cfgWritable = (fwrite($fp, '1') !== false);
|
||||||
|
fclose($fp);
|
||||||
|
@unlink($probeFile);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
$raw = (string)(Db::settingsAll()['config'] ?? '');
|
$c = ny_load_config();
|
||||||
} catch (Throwable $e) {
|
|
||||||
return null;
|
return [
|
||||||
|
'storage' => ny_config_source(),
|
||||||
|
'data_dir' => $dir,
|
||||||
|
'dir_writable' => is_dir($dir) && is_writable($dir),
|
||||||
|
'cfg_file' => $file,
|
||||||
|
'cfg_writable' => $cfgWritable,
|
||||||
|
'db_connected' => $dbOk,
|
||||||
|
'db_writable' => $dbWritable,
|
||||||
|
'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
|
||||||
|
'has_username' => trim((string)($c['username'] ?? '')) !== '',
|
||||||
|
'has_password' => ((string)($c['password'] ?? '')) !== '',
|
||||||
|
'username' => (string)($c['username'] ?? ''),
|
||||||
|
'save_error' => ny_config_save_error(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
|
||||||
|
* 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
|
||||||
|
*
|
||||||
|
* @return array{config:array,conn_changed:bool}
|
||||||
|
*/
|
||||||
|
function ny_apply_config_input(array $old, array $in): array
|
||||||
|
{
|
||||||
|
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
|
||||||
|
$new = $old;
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $in)) {
|
||||||
|
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||||
}
|
}
|
||||||
$data = json_decode($raw, true);
|
}
|
||||||
return is_array($data) ? $data : null;
|
// 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
|
||||||
|
if (array_key_exists('username', $in)) {
|
||||||
|
$u = trim((string)($in['username'] ?? ''));
|
||||||
|
if ($u === '') {
|
||||||
|
$new['username'] = (string)($old['username'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
|
||||||
|
if (array_key_exists('password', $in)) {
|
||||||
|
$pwdIn = (string)$in['password'];
|
||||||
|
if ($pwdIn !== '' && $pwdIn !== '******') {
|
||||||
|
$new['password'] = $pwdIn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
||||||
|
if (array_key_exists($k, $in)) {
|
||||||
|
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 布局配置
|
||||||
|
if (array_key_exists('layout', $in)) {
|
||||||
|
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
|
||||||
|
}
|
||||||
|
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
|
||||||
|
if (array_key_exists('history_display', $in)) {
|
||||||
|
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
||||||
|
}
|
||||||
|
// 成绩导出排序
|
||||||
|
if (array_key_exists('export_sort', $in)) {
|
||||||
|
$v = (string)$in['export_sort'];
|
||||||
|
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||||
|
}
|
||||||
|
if (array_key_exists('pass_rate', $in)) {
|
||||||
|
$pr = (float)$in['pass_rate'];
|
||||||
|
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
|
||||||
|
}
|
||||||
|
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
||||||
|
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||||
|
$new['timeout'] = (int)$new['timeout'];
|
||||||
|
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
||||||
|
if ($new['refresh_interval'] < 3) {
|
||||||
|
$new['refresh_interval'] = 3;
|
||||||
|
}
|
||||||
|
// 默认满分
|
||||||
|
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = 100;
|
||||||
|
}
|
||||||
|
$new['full_score'] = $fs;
|
||||||
|
|
||||||
|
// 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
|
||||||
|
$new['username'] = (string)($new['username'] ?? '');
|
||||||
|
$new['password'] = (string)($new['password'] ?? '');
|
||||||
|
|
||||||
|
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
||||||
|
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||||
|
$connChanged = false;
|
||||||
|
foreach ($connKeys as $k) {
|
||||||
|
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
|
||||||
|
$connChanged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['config' => $new, 'conn_changed' => $connChanged];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析「本卷满分 + 合格率」:
|
||||||
|
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||||
|
*
|
||||||
|
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
|
||||||
|
*/
|
||||||
|
function ny_resolve_score_cfg(string $projectId, array $c): array
|
||||||
|
{
|
||||||
|
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
|
||||||
|
|
||||||
|
$fs = (int)($pCfg['full_score'] ?? 0);
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = (int)($c['full_score'] ?? 100);
|
||||||
|
}
|
||||||
|
if ($fs < 1) {
|
||||||
|
$fs = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pr = $pCfg['pass_rate'] ?? null;
|
||||||
|
if ($pr === null || $pr === '') {
|
||||||
|
$pr = $c['pass_rate'] ?? 60;
|
||||||
|
}
|
||||||
|
$pr = (float)$pr;
|
||||||
|
if ($pr <= 0 || $pr > 100) {
|
||||||
|
$pr = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$fs, $pr];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP 会话目录是否可用(登录状态能不能保持住)。
|
||||||
|
* 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
|
||||||
|
*/
|
||||||
|
function ny_session_usable(?string &$why = null): bool
|
||||||
|
{
|
||||||
|
$dir = (string)@session_save_path();
|
||||||
|
// save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
|
||||||
|
if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
|
||||||
|
$dir = $m[1];
|
||||||
|
}
|
||||||
|
if ($dir === '') {
|
||||||
|
$dir = sys_get_temp_dir();
|
||||||
|
}
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$why = '会话目录不存在:' . $dir;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_writable($dir)) {
|
||||||
|
$why = '会话目录不可写:' . $dir;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||||
@@ -337,6 +668,7 @@ spl_autoload_register(function (string $class): void {
|
|||||||
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
||||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||||
|
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
||||||
'Db' => NY_ROOT . '/lib/Db.php',
|
'Db' => NY_ROOT . '/lib/Db.php',
|
||||||
];
|
];
|
||||||
if (isset($map[$class]) && is_file($map[$class])) {
|
if (isset($map[$class]) && is_file($map[$class])) {
|
||||||
|
|||||||
@@ -1,38 +1,51 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* 对外数据大屏(两种形态,由 URL 决定):
|
||||||
|
* 1) 无 ?class= → 大屏首页:先选试卷/考试,再以卡片网格列出该卷的班级,点击进入该班大屏;
|
||||||
|
* 2) 有 ?class=<班级名> → 该班独立大屏:只有试卷选择 / 自动刷新开关 / 全屏,
|
||||||
|
* 不出现班级选择、「← 主系统」「退出」,并且屏蔽浏览器后退键(退不回首页)。
|
||||||
|
* URL 仍支持 ?pid= 与 &auto= 便于分享与刷新恢复。
|
||||||
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
if (!ny_is_installed()) {
|
$pidParam = trim((string)($_GET['pid'] ?? ''));
|
||||||
header('Location: install.php');
|
$clsParam = trim((string)($_GET['class'] ?? ''));
|
||||||
exit;
|
$isClass = ($clsParam !== '');
|
||||||
}
|
|
||||||
if (!ny_is_auth()) {
|
/* 公开访问:带 ?class= 的班级大屏不需要登录。该班数据只暴露该班本身——
|
||||||
|
接口侧会先按班级裁剪再回传,拿不到全卷与其它班级的成绩。
|
||||||
|
大屏首页仍要求登录——它列出的是某卷下所有班级的入口。 */
|
||||||
|
$publicView = $isClass && !ny_is_auth();
|
||||||
|
if (!$isClass && !ny_is_auth()) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$pageTitle = $isClass ? ($clsParam . ' · 班级考试大屏') : '数据大屏 · 选择班级';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>数据看板 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
<title><?= htmlspecialchars($pageTitle) ?> · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="dash">
|
<!-- dash-class = 单班大屏(隐藏班级选择/返回/退出 + 锁后退);dash-home = 首页班级入口 -->
|
||||||
|
<body class="dash <?= $isClass ? 'dash-class' : 'dash-home' ?>">
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<div class="dash-hero">
|
<div class="dash-hero">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
<?php if ($isClass): ?>
|
||||||
<strong>NY问卷考试系统 · 数据看板</strong>
|
<span class="brand-cls" id="heroClass"><?= htmlspecialchars($clsParam) ?></span>
|
||||||
<small>班级整体情况 · 面向班主任与学生</small>
|
<?php endif; ?>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-controls">
|
<div class="dash-controls">
|
||||||
<select id="projectSel"><option value="">请选择问卷 / 考试</option></select>
|
<select id="projectSel" style="min-width:160px"><option value="">请选择问卷 / 考试</option></select>
|
||||||
|
<span id="classSelWrap"><select id="classSel" style="min-width:140px" disabled><option value="">请先选问卷</option></select></span>
|
||||||
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
||||||
<select id="intervalSel" style="width:auto">
|
<select id="intervalSel" style="width:auto">
|
||||||
<option value="5">5 秒</option>
|
<option value="5">5 秒</option>
|
||||||
@@ -43,52 +56,55 @@ if (!ny_is_auth()) {
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn" id="btnRefresh">刷新</button>
|
<button class="btn" id="btnRefresh">刷新</button>
|
||||||
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
<button class="btn" id="btnFullscreen" title="切换全屏">⛶ 全屏</button>
|
||||||
|
<a class="btn back" id="linkIndex" href="index.php" title="返回主系统(成绩明细 / 名单 / 抽取)">← 主系统</a>
|
||||||
|
<?php if (!$publicView): ?>
|
||||||
|
<a class="btn back" id="linkLogout" href="logout.php" title="退出登录">退出</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;">
|
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span id="heroSub">选择一个问卷与班级查看考试整体情况</span>
|
||||||
|
<?php if ($publicView): ?><span class="pub-tag" title="本页面为公开页面,无需登录即可查看">公开 · 无需登录</span><?php endif; ?>
|
||||||
|
<span class="spacer" style="flex:1"></span>
|
||||||
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= 总览 ================= -->
|
<!-- ================= 看板区:首页=班级入口卡片,单班页=该班看板 ================= -->
|
||||||
<div id="overview">
|
<div id="board">
|
||||||
<div class="sec-title">整体概况</div>
|
<!-- 5 个汇总卡片(仅单班大屏使用) -->
|
||||||
<section class="cards" id="summary">
|
<section class="cards" id="boardSummary">
|
||||||
<div class="card accent-blue"><span>名单总人数</span><b id="oTotal">-</b></div>
|
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
|
||||||
<div class="card ok accent-green"><span>已提交</span><b id="oSubmit">-</b></div>
|
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</b></div>
|
||||||
<div class="card warn accent-orange"><span>缺考</span><b id="oAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||||
<div class="card accent-purple"><span>提交率</span><b id="oRate">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="oAvg">-</b></div>
|
<div class="card accent-indigo"><span>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
|
||||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="oPass">-</b></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="sec-title">各班整体情况 <span class="tag-gray" id="classCount"></span>
|
<div class="sec-title" id="boardTitle">分组对比 / 考试成员</div>
|
||||||
<span class="hint" style="margin-left:auto;font-weight:500">点击班级卡片查看该班详情</span>
|
<div id="boardBody">
|
||||||
|
<div class="empty">请选择问卷与班级查看考试整体情况</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="class-grid" id="classGrid">
|
|
||||||
<div class="empty">请选择问卷 / 考试以查看各班数据</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ================= 班级详情 ================= -->
|
|
||||||
<div id="detail" class="hidden">
|
|
||||||
<div class="detail-head">
|
|
||||||
<button class="btn" id="btnBack">← 返回总览</button>
|
|
||||||
<h2 id="detailTitle">班级详情</h2>
|
|
||||||
<span class="tag-gray" id="detailSub"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="cards" id="detailSummary"></section>
|
|
||||||
|
|
||||||
<div class="sec-title" id="groupSecTitle">分组对比</div>
|
|
||||||
<div id="groupCompare"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多次刷题明细弹窗 -->
|
||||||
|
<div class="modal" id="attemptsModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close onclick="closeAttempts()">×</button></div>
|
||||||
|
<div class="modal-body" id="attBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 弹窗:点击遮罩或带 data-close 的元素关闭
|
||||||
|
document.querySelectorAll('.modal').forEach(m => {
|
||||||
|
m.addEventListener('click', e => {
|
||||||
|
if (e.target === m || e.target.hasAttribute('data-close')) m.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
const $$ = s => Array.from(document.querySelectorAll(s));
|
|
||||||
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
function rateColor(r){
|
function rateColor(r){
|
||||||
if (r >= 90) return 'var(--ok)';
|
if (r >= 90) return 'var(--ok)';
|
||||||
@@ -96,50 +112,155 @@ function rateColor(r){
|
|||||||
return 'var(--danger)';
|
return 'var(--danger)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 本地存储(缓存问卷列表/看板数据,重开即恢复) */
|
/* 成绩红绿:一律按管理员在后台设的本卷「满分 + 合格率」判定,取接口下发的 DASH。
|
||||||
|
⚠️ DASH 是 let 声明的,不会挂到 window 上——写成 window.DASH 永远拿到 undefined,
|
||||||
|
结果就是悄悄回退成 100 分 / 60% 的出厂默认值,后台改了合格率也不生效。 */
|
||||||
|
function scoreGrade(sc){
|
||||||
|
const d = (typeof DASH !== 'undefined') ? DASH : null;
|
||||||
|
const full = (d && d.full_score) ? Number(d.full_score) : 100;
|
||||||
|
const pass = (d && d.pass_rate != null) ? Number(d.pass_rate) : 60;
|
||||||
|
if (!(full > 0) || sc == null || sc === '') return { cls: '', pct: null, pass: pass, line: 0 };
|
||||||
|
const pct = Number(sc) / full * 100;
|
||||||
|
if (!isFinite(pct)) return { cls: '', pct: null, pass: pass, line: 0 };
|
||||||
|
// 合格线本身高过 90% 时绿线跟着抬上去,免得"合格了却显示成黄色"
|
||||||
|
const hi = Math.max(90, pass);
|
||||||
|
return {
|
||||||
|
cls: pct >= hi ? 'score-high' : (pct >= pass ? 'score-mid' : 'score-low'),
|
||||||
|
pct: pct,
|
||||||
|
pass: pass,
|
||||||
|
line: Math.round(full * pass / 100 * 10) / 10, // 合格线折算成分数, tooltip 用
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FLIP 行移动画:新数据行滑到新排名、其它行顺势下移(尊重 prefers-reduced-motion) */
|
||||||
|
const REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
function captureFlip(container){
|
||||||
|
const m = new Map();
|
||||||
|
if (!container) return m;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (sid) m.set(sid, tr.getBoundingClientRect().top);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function playFlip(container, prev){
|
||||||
|
if (!container || !prev || REDUCED_MOTION) return;
|
||||||
|
container.querySelectorAll('tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
if (!sid) return;
|
||||||
|
const oldTop = prev.get(sid);
|
||||||
|
if (oldTop === undefined) { tr.classList.add('row-drop-in'); return; }
|
||||||
|
const dy = oldTop - tr.getBoundingClientRect().top;
|
||||||
|
if (Math.abs(dy) < 1) return;
|
||||||
|
tr.style.transition = 'none';
|
||||||
|
tr.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
void tr.offsetWidth;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tr.style.transition = 'transform .42s cubic-bezier(.22,.61,.36,1)';
|
||||||
|
tr.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL 状态:?pid=&class=&auto=1|0 */
|
||||||
|
function readUrl(){
|
||||||
|
const u = new URL(location.href);
|
||||||
|
return {
|
||||||
|
pid: u.searchParams.get('pid') || '',
|
||||||
|
cls: u.searchParams.get('class') || '',
|
||||||
|
auto: u.searchParams.get('auto'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function writeUrl({pid, cls, auto}){
|
||||||
|
const u = new URL(location.href);
|
||||||
|
if (pid !== undefined) u.searchParams.set('pid', pid);
|
||||||
|
if (cls !== undefined) u.searchParams.set('class', cls);
|
||||||
|
if (auto !== undefined) u.searchParams.set('auto', auto ? '1' : '0');
|
||||||
|
// 注意:replaceState 不传 state 会把它清成 null,把后退锁的标志位一起抹掉,
|
||||||
|
// 所以有锁时原样写回(即便如此后退仍会被 popstate 兜住,这里是顺手保持一致)
|
||||||
|
const st = (history.state && history.state.nyLock) ? { nyLock: 1 } : null;
|
||||||
|
history.replaceState(st, '', u.pathname + (u.search ? u.search : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 本地缓存 */
|
||||||
const LS = {
|
const LS = {
|
||||||
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
|
get(k, def){ try { const v = localStorage.getItem(k); return v == null ? def : JSON.parse(v); } catch(e){ return def; } },
|
||||||
set(k, v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch(e){} },
|
set(k, v){ try { localStorage.setItem(k, v); } catch(e){} },
|
||||||
del(k){ try { localStorage.removeItem(k); } catch(e){} }
|
|
||||||
};
|
};
|
||||||
const LS_PROJECTS = 'ny_projects';
|
const LS_PROJECTS = 'ny_projects';
|
||||||
const LS_DASH = pid => 'ny_dashboard_' + pid;
|
const LS_BOARD = (pid, cls) => 'ny_board_' + pid + '_' + encodeURIComponent(cls);
|
||||||
|
const LS_DASH_AUTO = 'ny_dash_auto';
|
||||||
|
const LS_DASH_INTERVAL= 'ny_dash_interval';
|
||||||
|
|
||||||
let DASH = null; // 最近一次看板数据
|
let DASH = null;
|
||||||
let CUR = null; // 当前查看的班级名
|
let CUR = readUrl();
|
||||||
|
|
||||||
async function api(action, opts){
|
/* 形态:URL 带 class = 单班大屏(不显示班级选择、锁后退);否则是「首页 → 选班级进入」 */
|
||||||
opts = opts || {};
|
const IS_CLASS = !!CUR.cls;
|
||||||
|
/* 当前班级:单班页取自 URL,首页取自下拉 */
|
||||||
|
function currentClass(){ return IS_CLASS ? (CUR.cls || '') : ($('#classSel') ? $('#classSel').value : ''); }
|
||||||
|
|
||||||
|
/* ---------- API ---------- */
|
||||||
|
async function api(action, params){
|
||||||
let url = 'api.php?action=' + encodeURIComponent(action);
|
let url = 'api.php?action=' + encodeURIComponent(action);
|
||||||
if (opts.params) {
|
if (params) for (const k in params) url += '&' + k + '=' + encodeURIComponent(params[k] || '');
|
||||||
for (const k in opts.params) url += '&' + k + '=' + encodeURIComponent(opts.params[k]);
|
|
||||||
}
|
|
||||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j && j.ok) return j.data;
|
if (j && j.ok) return j.data;
|
||||||
throw new Error((j && (j.msg || j.error)) || '请求失败');
|
const err = new Error((j && (j.msg || j.error)) || '请求失败');
|
||||||
|
// 401 = 本系统会话已失效,同样需要重新登录(防死循环闸门见 handleAuthFail)
|
||||||
|
err.auth = !!(j && j.extra && j.extra.auth) || res.status === 401;
|
||||||
|
err.extra = (j && j.extra) || null;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 问卷列表:双策略识别文件夹 + 缓存优先 ---------- */
|
/* ---------- 鉴权失败:防反复跳登录页的死循环 ----------
|
||||||
|
* 若刚登录完(120 秒内)又鉴权失败,说明"重新登录"解决不了问题(多半是凭据没存住),
|
||||||
|
* 此时不再跳转,直接把服务端原因 + 落盘诊断显示出来,让人能自查。 */
|
||||||
|
function authJustLoggedIn(){
|
||||||
|
try {
|
||||||
|
const t = parseInt(sessionStorage.getItem('ny_login_at') || '0', 10);
|
||||||
|
return t > 0 && (Date.now() - t) < 120000;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function authJumpCount(){
|
||||||
|
try { return parseInt(sessionStorage.getItem('ny_auth_jumps') || '0', 10) || 0; } catch (e) { return 0; }
|
||||||
|
}
|
||||||
|
function showAuthFailureBox(e){
|
||||||
|
const d = (e.extra && e.extra.diag) || null;
|
||||||
|
const yn = v => (v ? '是' : '否');
|
||||||
|
const lines = d ? [
|
||||||
|
'配置来源:' + (d.storage || '-') + '(配置文件可写=' + yn(d.cfg_writable) + ')',
|
||||||
|
'数据目录:' + (d.data_dir || '-') + '(可写=' + yn(d.dir_writable) + ')',
|
||||||
|
'数据库:' + (d.db_connected ? ('已连接(可写=' + yn(d.db_writable) + ')') : ('未连接 ' + (d.db_error || ''))),
|
||||||
|
'已存账号:' + (d.has_username ? (d.username || '已设置') : '空 ← 这就是「未配置账号」的原因'),
|
||||||
|
'已存密码:' + (d.has_password ? '已保存' : '空'),
|
||||||
|
d.save_error ? ('保存异常:' + d.save_error) : '',
|
||||||
|
].filter(Boolean) : [];
|
||||||
|
$('#boardBody').innerHTML =
|
||||||
|
'<div class="empty" style="text-align:left;line-height:1.9">' +
|
||||||
|
'<b>无法访问卷王</b><br>' + esc(e.message || '') +
|
||||||
|
(lines.length ? '<pre style="white-space:pre-wrap;margin:10px 0 0;text-align:left;font-size:12px">' + esc(lines.join('\n')) + '</pre>' : '') +
|
||||||
|
'<div style="margin-top:12px"><a class="btn btn-primary" href="login.php">重新登录</a></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 问卷列表(双策略文件夹识别 + 缓存) ---------- */
|
||||||
function renderProjects(list){
|
function renderProjects(list){
|
||||||
const sel = $('#projectSel');
|
const sel = $('#projectSel');
|
||||||
const keep = sel.value;
|
const keep = sel.value;
|
||||||
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
|
sel.innerHTML = '<option value="">请选择问卷 / 考试</option>';
|
||||||
// 双策略识别文件夹:mode 含 folder,或被其他项目作为 parent_id 引用
|
|
||||||
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
|
const isFolder = p => (p.mode || '').toLowerCase().includes('folder');
|
||||||
const parentIds = new Set();
|
const parentIds = new Set();
|
||||||
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
|
list.forEach(p => { const pid = (p.parent_id || '0'); if (pid && pid !== '0' && pid !== '') parentIds.add(pid); });
|
||||||
const folderSet = new Set();
|
const folderSet = new Set();
|
||||||
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
|
list.forEach(p => { if (isFolder(p) || parentIds.has(p.id)) folderSet.add(p.id); });
|
||||||
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
|
const childOf = fid => list.filter(p => !folderSet.has(p.id) && (p.parent_id || '0') === fid);
|
||||||
|
|
||||||
// 根项
|
|
||||||
list.filter(p => !folderSet.has(p.id)).forEach(p => {
|
list.filter(p => !folderSet.has(p.id)).forEach(p => {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
|
o.value = p.id; o.textContent = (p.mode ? '[' + p.mode + '] ' : '') + p.name + '(' + (p.total || 0) + '份)';
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
// 文件夹
|
|
||||||
list.filter(p => folderSet.has(p.id)).forEach(f => {
|
list.filter(p => folderSet.has(p.id)).forEach(f => {
|
||||||
const kids = childOf(f.id);
|
const kids = childOf(f.id);
|
||||||
if (!kids.length) return;
|
if (!kids.length) return;
|
||||||
@@ -152,190 +273,359 @@ function renderProjects(list){
|
|||||||
});
|
});
|
||||||
sel.appendChild(og);
|
sel.appendChild(og);
|
||||||
});
|
});
|
||||||
if (keep && list.some(p => p.id === keep)) sel.value = keep;
|
if (CUR.pid && list.some(p => p.id === CUR.pid)) sel.value = CUR.pid;
|
||||||
|
else if (keep && list.some(p => p.id === keep)) sel.value = keep;
|
||||||
else {
|
else {
|
||||||
const last = LS.get('ny_dash_last_pid', '');
|
const last = LS.get('ny_board_last_pid', '');
|
||||||
if (last && list.some(p => p.id === last)) sel.value = last;
|
if (last && list.some(p => p.id === last)) sel.value = last;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProjects(){
|
async function loadProjects(){
|
||||||
// 1) 先读本地缓存
|
|
||||||
const cached = LS.get(LS_PROJECTS, []);
|
const cached = LS.get(LS_PROJECTS, []);
|
||||||
if (Array.isArray(cached) && cached.length) {
|
if (Array.isArray(cached) && cached.length) renderProjects(cached);
|
||||||
renderProjects(cached);
|
|
||||||
}
|
|
||||||
// 2) 后台异步刷新
|
|
||||||
try {
|
try {
|
||||||
const list = await api('projects');
|
const list = await api('projects');
|
||||||
LS.set(LS_PROJECTS, list || []);
|
LS.set(LS_PROJECTS, JSON.stringify(list || []));
|
||||||
renderProjects(list || []);
|
renderProjects(list || []);
|
||||||
|
refreshClassSel(); // 项目变了刷新班级列表
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cached || !cached.length) {
|
if (!cached || !cached.length) {
|
||||||
$('#classGrid').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
|
$('#boardBody').innerHTML = '<div class="empty">加载问卷列表失败:' + esc(e.message) + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(d){
|
/* ---------- 班级列表(来自 roster) ---------- */
|
||||||
const o = d.overall || {};
|
async function refreshClassSel(){
|
||||||
$('#oTotal').textContent = o.roster_total ?? '-';
|
const sel = $('#classSel');
|
||||||
$('#oSubmit').textContent = o.submitted ?? '-';
|
const pid = $('#projectSel').value;
|
||||||
$('#oAbsent').textContent = o.absent ?? '-';
|
if (!pid) {
|
||||||
$('#oRate').textContent = (o.submit_rate != null ? o.submit_rate + '%' : '-');
|
sel.innerHTML = '<option value="">请先选问卷</option>';
|
||||||
$('#oAvg').textContent = o.avg_score != null ? o.avg_score : '-';
|
sel.disabled = true;
|
||||||
$('#oPass').textContent = (o.pass_rate != null ? o.pass_rate + '%' : '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClasses(d){
|
|
||||||
const classes = d.classes || [];
|
|
||||||
$('#classCount').textContent = classes.length ? classes.length + ' 个班级' : '';
|
|
||||||
if (!classes.length) {
|
|
||||||
$('#classGrid').innerHTML = '<div class="empty">暂无班级数据(名单未导入班级信息)</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#classGrid').innerHTML = classes.map(c => {
|
// 项目数据来自 api (dashboard),先调用一次拿班级
|
||||||
const rate = c.rate || 0;
|
try {
|
||||||
const color = rateColor(rate);
|
const data = await api('dashboard', dashParams({ projectId: pid }));
|
||||||
const gs = (c.groups || []).filter(g => g.group !== '(未分组)');
|
DASH = data;
|
||||||
const gtag = gs.length ? gs.map(g => esc(g.group) + '组').join(' / ') : '';
|
// 合格率阈值可配(全局或本卷独立),卡片标题实时反映
|
||||||
return '<div class="class-card clickable" data-cls="' + esc(c.class) + '">' +
|
const bl = document.getElementById('bPassLabel');
|
||||||
'<h3><span>' + esc(c.class) + '</span><span class="rate-num" style="color:' + color + '">' + rate + '%</span></h3>' +
|
if (bl) bl.textContent = '(≥' + (data.pass_rate == null ? 60 : data.pass_rate) + '%满分)';
|
||||||
'<div class="sub">已提交 ' + c.submitted + ' / 总人数 ' + c.total + ' · 缺考 ' + c.absent + '</div>' +
|
const classes = (data.classes || []).map(c => c.class);
|
||||||
'<div class="bar"><i style="width:' + rate + '%;background:' + color + '"></i></div>' +
|
sel.innerHTML = classes.length
|
||||||
'<div class="class-metrics">' +
|
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||||
'<div class="m"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
: '<option value="">暂无班级</option>';
|
||||||
'<div class="m"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
|
sel.disabled = !classes.length;
|
||||||
'<div class="m metric-ok"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
if (IS_CLASS) {
|
||||||
'<div class="m metric-danger"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>' +
|
/* 单班大屏:班级由 URL 决定,下拉只是隐藏的镜像。
|
||||||
'</div>' +
|
换卷后该班若不在新卷名单里 → 落到第一个班并把 URL 改对(不新增历史,避免影响后退锁)。 */
|
||||||
(gtag ? '<div class="grp-tag">' + gtag + '</div>' : '') +
|
if (classes.length) {
|
||||||
'</div>';
|
if (!classes.includes(CUR.cls)) { CUR.cls = classes[0]; writeUrl({ cls: CUR.cls }); }
|
||||||
}).join('');
|
sel.value = CUR.cls;
|
||||||
|
}
|
||||||
|
} else if (CUR.cls && classes.includes(CUR.cls)) {
|
||||||
|
sel.value = CUR.cls;
|
||||||
|
} else {
|
||||||
|
// 默认 class:上次选择 > 第一个
|
||||||
|
const last = LS.get('ny_board_last_cls_' + pid, '');
|
||||||
|
if (last && classes.includes(last)) sel.value = last;
|
||||||
|
else if (classes.length) sel.value = classes[0];
|
||||||
|
CUR.cls = sel.value;
|
||||||
|
}
|
||||||
|
renderBoard();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.auth) {
|
||||||
|
const jumps = authJumpCount();
|
||||||
|
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e); return; }
|
||||||
|
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||||
|
// 卷王账号未配置或登录失效:跳转登录页自愈
|
||||||
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.innerHTML = '<option value="">加载失败</option>';
|
||||||
|
sel.disabled = true;
|
||||||
|
$('#boardBody').innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 成员榜:排名 / 姓名 / 成绩(默认成绩降序) ---------- */
|
/* ---------- 成员榜(含多次刷题徽标与下钻) ---------- */
|
||||||
function membersTable(members){
|
function membersTable(members){
|
||||||
if (!members || !members.length) {
|
if (!members || !members.length) {
|
||||||
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
return '<div class="empty" style="padding:18px 0">暂无考试成员</div>';
|
||||||
}
|
}
|
||||||
return '<div class="member-list"><table class="grid compact">' +
|
return '<div class="member-list"><table class="grid compact">' +
|
||||||
'<thead><tr><th style="width:60px">排名</th><th>姓名</th><th style="width:80px">成绩</th></tr></thead><tbody>' +
|
'<thead><tr><th style="width:20%;text-align:right">排名</th><th style="width:55%">姓名</th><th style="width:25%" data-k="score">成绩</th></tr></thead><tbody>' +
|
||||||
members.map(m => {
|
members.map(m => {
|
||||||
const rk = m.rank;
|
const rk = (m.rank == null ? '' : m.rank);
|
||||||
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
const badge = rk === 1 ? 'rk rk-1' : (rk === 2 ? 'rk rk-2' : (rk === 3 ? 'rk rk-3' : 'rk'));
|
||||||
const sc = m.score;
|
const sc = m.score;
|
||||||
const cls = sc == null ? '' : (sc >= 90 ? 'score-high' : (sc >= 60 ? 'score-mid' : 'score-low'));
|
// 红绿由后台配置的满分成数 / 合格率决定(见 scoreGrade)
|
||||||
return '<tr>' +
|
const g = scoreGrade(sc);
|
||||||
|
const cls = g.cls;
|
||||||
|
const tip = g.cls ? ' title="占满分 ' + g.pct.toFixed(1) + '% 合格线 ' + g.line + ' 分"' : '';
|
||||||
|
const att = (m.attempts || 1) > 1;
|
||||||
|
const tag = att ? ' <span class="tag tag-att" title="查看 ' + m.attempts + ' 次提交明细">刷' + m.attempts + '次</span>' : '';
|
||||||
|
// 注意:不要把 sid 直接拼进 onclick="...",JSON.stringify 产生的双引号会提前闭合
|
||||||
|
// HTML 属性(onclick="openAttempts("123")"),点击必然报错。改用 data-sid + 事件委托。
|
||||||
|
return '<tr' + (att ? ' class="clickable"' : '') + ' data-sid="' + esc(m.sid) + '"' + (att ? ' title="查看提交明细"' : '') + '>' +
|
||||||
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
'<td class="num"><span class="' + badge + '">' + rk + '</span></td>' +
|
||||||
'<td class="name">' + esc(m.name) + '</td>' +
|
'<td class="name">' + esc(m.name) + tag + '</td>' +
|
||||||
'<td class="score num ' + cls + '">' + (sc == null ? '-' : sc) + '</td>' +
|
'<td class="score num ' + cls + '"' + tip + '>' + (sc == null ? '-' : sc) + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tbody></table></div>';
|
'</tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 班级详情:整体 + A/B 分组对比 ---------- */
|
/* ---------- 多次刷题下钻弹窗 ---------- */
|
||||||
function showClassDetail(name){
|
function openAttempts(sid){
|
||||||
if (!DASH) return;
|
const info = (window.MEMBER_ATT || {})[sid];
|
||||||
const c = (DASH.classes || []).find(x => x.class === name);
|
if (!info) return;
|
||||||
if (!c) return;
|
const list = info.list || [];
|
||||||
CUR = name;
|
const rows = list.map(a => {
|
||||||
|
// 下钻弹窗里的成绩用同一套红绿口径,避免"列表里是绿的、点开却是白的"
|
||||||
|
const g = scoreGrade(a.score);
|
||||||
|
const tmp = a.temp_save ? ' <span class="tag tag-gray">暂存</span>' : '';
|
||||||
|
const sc = a.score == null ? '<span class="muted">未判分</span>' : esc(a.score);
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="num">' + esc(a.submit_at) + '</td>' +
|
||||||
|
'<td class="num ' + g.cls + '">' + sc + tmp + '</td>' +
|
||||||
|
'<td class="num">' + esc(a.duration_txt || '-') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
$('#attTitle').textContent = info.name + ' · 共 ' + (info.attempts || list.length) + ' 次提交';
|
||||||
|
$('#attBody').innerHTML = list.length
|
||||||
|
? '<table class="grid compact att-table"><thead><tr>' +
|
||||||
|
'<th data-k="submit_ts" style="width:40%">提交时间</th>' +
|
||||||
|
'<th data-k="score" style="width:30%">成绩</th>' +
|
||||||
|
'<th data-k="duration" style="width:30%">考试时长</th>' +
|
||||||
|
'</tr></thead><tbody>' + rows + '</tbody></table>'
|
||||||
|
: '<div class="empty">无提交明细</div>';
|
||||||
|
document.querySelector('#attemptsModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeAttempts(){
|
||||||
|
const m = document.querySelector('#attemptsModal');
|
||||||
|
if (m) m.classList.remove('open');
|
||||||
|
}
|
||||||
|
// 成员榜下钻:事件委托(#boardBody 常驻,内部表格会被整体重绘)
|
||||||
|
(function bindMemberDrill(){
|
||||||
|
const box = document.querySelector('#boardBody');
|
||||||
|
if (!box) return;
|
||||||
|
box.addEventListener('click', e => {
|
||||||
|
const tr = e.target.closest ? e.target.closest('tr[data-sid]') : null;
|
||||||
|
if (!tr || !box.contains(tr)) return;
|
||||||
|
openAttempts(tr.getAttribute('data-sid'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
// Esc 关闭最上层弹窗
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape' && e.key !== 'Esc') return;
|
||||||
|
const open = Array.prototype.slice.call(document.querySelectorAll('.modal.open'));
|
||||||
|
if (open.length) open[open.length - 1].classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
$('#overview').classList.add('hidden');
|
/* ---------- 首页:班级入口卡片网格 ---------- */
|
||||||
$('#detail').classList.remove('hidden');
|
function clsCardHtml(c){
|
||||||
|
const rate = Number(c.rate || 0);
|
||||||
|
const total = dv(c.total, 0), sub = dv(c.submitted, 0), abs = dv(c.absent, 0);
|
||||||
|
const pr = Number(c.pass_rate == null ? 0 : c.pass_rate);
|
||||||
|
const kpi = (label, val, kc) =>
|
||||||
|
'<div class="cls-kpi ' + (kc || '') + '"><span>' + label + '</span><b>' + val + '</b></div>';
|
||||||
|
return '<div class="cls-card" data-cls="' + esc(c.class) + '" role="button" tabindex="0" ' +
|
||||||
|
'title="点击进入「' + esc(c.class) + '」的独立大屏">' +
|
||||||
|
'<div class="cls-head"><span class="cls-name">' + esc(c.class) + '</span>' +
|
||||||
|
'<span class="cls-go">进入 ›</span></div>' +
|
||||||
|
'<div class="cls-kpis">' +
|
||||||
|
kpi('总人数', total) +
|
||||||
|
kpi('已提交', sub, 'k-ok') +
|
||||||
|
kpi('缺考', abs, abs > 0 ? 'k-warn' : '') +
|
||||||
|
kpi('平均分', dv(c.avg)) +
|
||||||
|
kpi('合格率', pv(pr), 'k-ok') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cls-bar"><i style="width:' + rate + '%;background:' + rateColor(rate) + '"></i></div>' +
|
||||||
|
'<div class="cls-sub"><span>提交率 ' + rate + '%</span>' +
|
||||||
|
(c.attempts > c.submitted ? '<span>共 ' + dv(c.attempts, 0) + ' 次刷题</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
$('#detailTitle').textContent = c.class;
|
/** 首页渲染:把该卷的班级列成卡片,点击进入该班独立大屏(带 pid/auto 便于回到同一状态) */
|
||||||
$('#detailSub').textContent = '总人数 ' + c.total + ' · 已提交 ' + c.submitted + ' · 缺考 ' + c.absent;
|
function renderClassCards(){
|
||||||
|
const pid = $('#projectSel').value;
|
||||||
const color = rateColor(c.rate || 0);
|
if (!pid || !DASH) { renderPlaceholderBoard('请先选择一份问卷 / 考试'); return; }
|
||||||
$('#detailSummary').innerHTML =
|
const list = DASH.classes || [];
|
||||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + c.rate + '%</b></div>' +
|
const bt = document.getElementById('boardTitle');
|
||||||
'<div class="card ok accent-green"><span>已提交</span><b>' + c.submitted + '</b></div>' +
|
if (bt) bt.innerHTML = '班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span>';
|
||||||
'<div class="card warn accent-orange"><span>缺考</span><b>' + c.absent + '</b></div>' +
|
const box = $('#boardBody');
|
||||||
'<div class="card accent-blue"><span>总人数</span><b>' + c.total + '</b></div>' +
|
if (!list.length) {
|
||||||
'<div class="card accent-teal"><span>平均分</span><b>' + (c.avg != null ? c.avg : '-') + '</b></div>' +
|
box.innerHTML = '<div class="empty">该问卷没有解析出班级,请检查名单的「班级」列</div>';
|
||||||
'<div class="card accent-indigo"><span>合格率</span><b>' + c.pass_rate + '%</b></div>' +
|
|
||||||
'<div class="card accent-green"><span>最高分</span><b>' + (c.max != null ? c.max : '-') + '</b></div>' +
|
|
||||||
'<div class="card accent-red"><span>最低分</span><b>' + (c.min != null ? c.min : '-') + '</b></div>';
|
|
||||||
|
|
||||||
// 分组对比:过滤掉“未分组”,按组名排序(A 在左,B 在右)
|
|
||||||
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
|
||||||
const box = $('#groupCompare');
|
|
||||||
if (!groups.length) {
|
|
||||||
$('#groupSecTitle').textContent = '考试成员';
|
|
||||||
box.innerHTML = '<div class="gpanel">' +
|
|
||||||
'<div class="ghead"><strong>本班考试成员</strong>' +
|
|
||||||
'<span class="rate-num">' + c.submitted + ' 人</span></div>' +
|
|
||||||
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列</div>' +
|
|
||||||
membersTable(c.members) + '</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#groupSecTitle').textContent = '分组对比(' + groups.map(g => g.group).join(' vs ') + ')';
|
box.innerHTML = '<div class="cls-grid">' + list.map(clsCardHtml).join('') + '</div>';
|
||||||
|
const _hs = document.getElementById('heroSub');
|
||||||
|
if (_hs) _hs.textContent = '共 ' + list.length + ' 个班级 · 点击卡片进入该班大屏';
|
||||||
|
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入某班独立大屏:整页跳转(不是前端路由,浏览器后退键会被下面的锁拦掉) */
|
||||||
|
function enterClass(cls){
|
||||||
|
const pid = $('#projectSel').value;
|
||||||
|
if (!pid || !cls) return;
|
||||||
|
const u = new URL(location.href);
|
||||||
|
u.searchParams.set('pid', pid);
|
||||||
|
u.searchParams.set('class', cls);
|
||||||
|
u.searchParams.set('auto', $('#autoRefresh').checked ? '1' : '0');
|
||||||
|
location.href = u.pathname + u.search;
|
||||||
|
}
|
||||||
|
// 卡片点击 / 回车进入(事件委托:卡片网格每次整体重绘,绑在常驻的 #boardBody 上)
|
||||||
|
(function bindClsCards(){
|
||||||
|
const box = document.querySelector('#boardBody');
|
||||||
|
if (!box) return;
|
||||||
|
const hit = e => (e.target && e.target.closest ? e.target.closest('.cls-card') : null);
|
||||||
|
box.addEventListener('click', e => {
|
||||||
|
const card = hit(e);
|
||||||
|
if (card) enterClass(card.getAttribute('data-cls') || '');
|
||||||
|
});
|
||||||
|
box.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
const card = hit(e);
|
||||||
|
if (!card) return;
|
||||||
|
e.preventDefault();
|
||||||
|
enterClass(card.getAttribute('data-cls') || '');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------- 看板占位(未选问卷/班级) ---------- */
|
||||||
|
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||||||
|
const PLACEHOLDER_ACCENT = {
|
||||||
|
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||||||
|
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||||||
|
};
|
||||||
|
function renderPlaceholderBoard(msg){
|
||||||
|
const m = msg || '选择一个问卷与班级查看考试整体情况';
|
||||||
|
const cards = [
|
||||||
|
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||||
|
['平均分','-'],['合格率','-']
|
||||||
|
];
|
||||||
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
|
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
||||||
|
'<div class="card ' + PLACEHOLDER_ACCENT[c[0]] + '"><span>' + c[0] + '</span><b>' + c[1] + '</b></div>').join('');
|
||||||
|
$('#boardBody').innerHTML = '<div class="empty">' + esc(m) + '</div>';
|
||||||
|
const bt = document.getElementById('boardTitle');
|
||||||
|
if (bt) bt.innerHTML = IS_CLASS ? '分组对比 / 考试成员'
|
||||||
|
: '班级入口 <span class="tag-gray">选好试卷后显示可进入的班级</span>';
|
||||||
|
// 注意:heroTitle 已随导航栏精简移除,只保留 heroSub(承载当前问卷/班级提示)
|
||||||
|
const _hs = document.getElementById('heroSub');
|
||||||
|
if (_hs) _hs.textContent = m;
|
||||||
|
const d = $('#liveDot'); if (d) { d.classList.remove('on','stale'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 实时状态指示 ---------- */
|
||||||
|
function markLive(){
|
||||||
|
const d = $('#liveDot');
|
||||||
|
if (d) { d.classList.remove('stale'); d.classList.add('on'); }
|
||||||
|
setTimeout(() => { const e = $('#liveDot'); if (e) e.classList.remove('on'); }, 900);
|
||||||
|
}
|
||||||
|
function markStale(msg){
|
||||||
|
const d = $('#liveDot');
|
||||||
|
if (d) { d.classList.remove('on'); d.classList.add('stale'); }
|
||||||
|
const lu = $('#lastUpdate');
|
||||||
|
// 具体原因放进 title(鼠标悬停可见),正文保持简短
|
||||||
|
if (lu) { lu.textContent = '刷新中断(仍显示上次数据)'; if (msg) lu.title = String(msg); }
|
||||||
|
}
|
||||||
|
function handleAuthFail(e){
|
||||||
|
const jumps = authJumpCount();
|
||||||
|
if (authJustLoggedIn() || jumps >= 2) { showAuthFailureBox(e || new Error('鉴权失败')); return; }
|
||||||
|
try { sessionStorage.setItem('ny_auth_jumps', String(jumps + 1)); } catch (err) {}
|
||||||
|
$('#boardBody').innerHTML = '<div class="empty">卷王账号未配置或登录已失效,<a href="login.php">点击重新登录</a></div>';
|
||||||
|
setTimeout(() => { location.href = 'login.php'; }, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 渲染单班看板 ---------- */
|
||||||
|
/** 数值兜底:后端字段缺失时显示占位符,避免界面出现 NaN / undefined */
|
||||||
|
function dv(v, dash){ return (v === undefined || v === null || v === '' || Number.isNaN(v)) ? (dash || '-') : v; }
|
||||||
|
function pv(v){ return dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%'); }
|
||||||
|
|
||||||
|
function renderBoard(){
|
||||||
|
// 首页形态:不出单班看板,改为渲染班级入口卡片(boardSummary 由 CSS 隐藏)
|
||||||
|
if (!IS_CLASS) { renderClassCards(); return; }
|
||||||
|
|
||||||
|
const pid = $('#projectSel').value;
|
||||||
|
const cls = currentClass();
|
||||||
|
CUR.pid = pid; CUR.cls = cls;
|
||||||
|
writeUrl({ pid, cls });
|
||||||
|
|
||||||
|
if (!pid || !cls || !DASH) {
|
||||||
|
renderPlaceholderBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LS.set('ny_board_last_pid', JSON.stringify(pid));
|
||||||
|
LS.set('ny_board_last_cls_' + pid, JSON.stringify(cls));
|
||||||
|
|
||||||
|
const c = (DASH.classes || []).find(x => x.class === cls);
|
||||||
|
if (!c) {
|
||||||
|
$('#boardBody').innerHTML = '<div class="empty">该班级无数据(名单中无此班级)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部提示:当前班级 / 问卷(heroTitle 已移除,只更新 heroSub)
|
||||||
|
const projectName = ($('#projectSel').selectedOptions[0] || {}).textContent || '';
|
||||||
|
const _hc = document.getElementById('heroClass');
|
||||||
|
if (_hc) _hc.textContent = cls + ' · 该班独立大屏';
|
||||||
|
|
||||||
|
// 构建"学号 -> 多次刷题明细"映射(班级级 members 已含全班所有人的 attempts_list)
|
||||||
|
const MAP = {};
|
||||||
|
(c.members || []).forEach(m => {
|
||||||
|
if ((m.attempts || 1) > 1) {
|
||||||
|
MAP[m.sid] = { name: m.name, attempts: m.attempts, list: m.attempts_list || [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.MEMBER_ATT = MAP;
|
||||||
|
|
||||||
|
const overall = DASH.overall || {};
|
||||||
|
const totalAttempts = (overall.attempts_total || 0);
|
||||||
|
const _hs2 = document.getElementById('heroSub');
|
||||||
|
if (_hs2) _hs2.textContent = cls + ' · 问卷:' + projectName.replace(/\uff08\d+份\uff09$/, '') + ' · ' +
|
||||||
|
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
|
||||||
|
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||||
|
|
||||||
|
// 5 卡汇总(提交率卡片已移除)
|
||||||
|
$('#boardSummary').innerHTML =
|
||||||
|
'<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
|
||||||
|
'<div class="card ok accent-green"><span>已提交</span><b>' + dv(c.submitted, 0) + '</b></div>' +
|
||||||
|
'<div class="card warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
|
||||||
|
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</b></div>' +
|
||||||
|
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
|
||||||
|
|
||||||
|
const groups = (c.groups || []).filter(g => g.group !== '(未分组)');
|
||||||
|
const box = $('#boardBody');
|
||||||
|
if (!groups.length) {
|
||||||
|
box.innerHTML = '<div class="gpanel">' +
|
||||||
|
'<div class="ghead"><strong>本班考试成员</strong>' +
|
||||||
|
'<span class="rate-num">' + dv(c.submitted, 0) + ' 人</span></div>' +
|
||||||
|
'<div class="sub">该班级未设置分组(A/B),以下按成绩从高到低排列' +
|
||||||
|
(c.attempts > c.submitted ? ' · 共 ' + c.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
|
membersTable(c.members) + '</div>';
|
||||||
|
} else {
|
||||||
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
box.innerHTML = '<div class="group-compare">' + groups.map(g => {
|
||||||
const gc = rateColor(g.rate || 0);
|
const gc = rateColor(g.rate || 0);
|
||||||
return '<div class="gpanel">' +
|
return '<div class="gpanel">' +
|
||||||
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
'<div class="ghead"><strong>' + esc(g.group) + ' 组</strong>' +
|
||||||
'<span class="rate-num" style="color:' + gc + '">' + g.rate + '%</span></div>' +
|
'<span class="rate-num" style="color:' + gc + '">' + dv(g.rate, 0) + '%</span></div>' +
|
||||||
'<div class="sub">已提交 ' + g.submitted + ' / 总人数 ' + g.total + ' · 缺考 ' + g.absent + '</div>' +
|
'<div class="sub">已提交 ' + dv(g.submitted, 0) + ' / 总人数 ' + dv(g.total, 0) + ' · 缺考 ' + dv(g.absent, 0) +
|
||||||
'<div class="bar"><i style="width:' + g.rate + '%;background:' + gc + '"></i></div>' +
|
(g.attempts > g.submitted ? ' · 共 ' + g.attempts + ' 次刷题' : '') + '</div>' +
|
||||||
|
'<div class="bar"><i style="width:' + dv(g.rate, 0) + '%;background:' + gc + '"></i></div>' +
|
||||||
'<div class="class-metrics">' +
|
'<div class="class-metrics">' +
|
||||||
'<div class="m"><span>平均分</span><b>' + (g.avg != null ? g.avg : '-') + '</b></div>' +
|
'<div class="m"><span>平均分</span><b>' + dv(g.avg) + '</b></div>' +
|
||||||
'<div class="m"><span>合格率</span><b>' + g.pass_rate + '%</b></div>' +
|
'<div class="m"><span>合格率</span><b>' + pv(g.pass_rate) + '</b></div>' +
|
||||||
'<div class="m metric-ok"><span>最高分</span><b>' + (g.max != null ? g.max : '-') + '</b></div>' +
|
'<div class="m metric-ok"><span>最高分</span><b>' + dv(g.max) + '</b></div>' +
|
||||||
'<div class="m metric-danger"><span>最低分</span><b>' + (g.min != null ? g.min : '-') + '</b></div>' +
|
'<div class="m metric-danger"><span>最低分</span><b>' + dv(g.min) + '</b></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mem-title">考试成员 · 按成绩降序(' + g.submitted + ' 人)</div>' +
|
'<div class="mem-title">考试成员 · 按成绩降序(' + dv(g.submitted, 0) + ' 人)</div>' +
|
||||||
membersTable(g.members) +
|
membersTable(g.members) +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
|
||||||
|
|
||||||
function backToOverview(){
|
|
||||||
CUR = null;
|
|
||||||
$('#detail').classList.add('hidden');
|
|
||||||
$('#overview').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard(){
|
|
||||||
const pid = $('#projectSel').value;
|
|
||||||
if (!pid) {
|
|
||||||
$('#classGrid').innerHTML = '<div class="empty">请选择问卷 / 考试以查看各班数据</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 缓存优先:先渲染上次保存的看板,避免空白
|
|
||||||
const cached = LS.get(LS_DASH(pid), null);
|
|
||||||
if (cached && cached.classes) {
|
|
||||||
DASH = cached;
|
|
||||||
renderSummary(DASH);
|
|
||||||
renderClasses(DASH);
|
|
||||||
$('#lastUpdate').textContent = (cached.updated_at || '—') + '(缓存)';
|
|
||||||
if (CUR) {
|
|
||||||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
|
||||||
if (still) showClassDetail(CUR); else backToOverview();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 后台拉真实数据
|
|
||||||
try {
|
|
||||||
const fresh = await api('dashboard', { params: { projectId: pid } });
|
|
||||||
DASH = fresh;
|
|
||||||
LS.set(LS_DASH(pid), fresh);
|
|
||||||
LS.set('ny_dash_last_pid', pid);
|
|
||||||
renderSummary(DASH);
|
|
||||||
renderClasses(DASH);
|
|
||||||
$('#lastUpdate').textContent = DASH.updated_at || '-';
|
|
||||||
$('#liveDot').classList.add('on');
|
|
||||||
setTimeout(() => $('#liveDot').classList.remove('on'), 900);
|
|
||||||
if (CUR) {
|
|
||||||
const still = (DASH.classes || []).some(x => x.class === CUR);
|
|
||||||
if (still) showClassDetail(CUR); else backToOverview();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!cached) {
|
|
||||||
$('#classGrid').innerHTML = '<div class="empty">数据加载失败:' + esc(e.message) + '</div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,10 +634,21 @@ function toggleFullscreen(){
|
|||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
const req = el.requestFullscreen || el.webkitRequestFullscreen;
|
||||||
if (req) req.call(el);
|
if (req) {
|
||||||
|
const p = req.call(el);
|
||||||
|
// 部分浏览器(尤其 Safari)会返回 Promise,被拒绝时静默吞掉会让人以为"点了没反应"
|
||||||
|
if (p && typeof p.catch === 'function') {
|
||||||
|
p.catch(() => { alert('当前浏览器/环境不允许进入全屏,请按 F11 手动切换'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 注意:exitFullscreen 挂在 document 上,不是 documentElement 上
|
||||||
|
// (写成 el.exitFullscreen 恒为 undefined → 退出全屏点了没反应)
|
||||||
const ex = document.exitFullscreen || document.webkitExitFullscreen;
|
const ex = document.exitFullscreen || document.webkitExitFullscreen;
|
||||||
if (ex) ex.call(document);
|
if (ex) {
|
||||||
|
const p = ex.call(document);
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onFsChange(){
|
function onFsChange(){
|
||||||
@@ -357,31 +658,144 @@ function onFsChange(){
|
|||||||
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
|
if (btn) btn.textContent = on ? '⛶ 退出全屏' : '⛶ 全屏';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 自动刷新 ---------- */
|
||||||
|
let timer = null;
|
||||||
function schedule(){
|
function schedule(){
|
||||||
if (window.__timer) clearInterval(window.__timer);
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
if (!$('#autoRefresh').checked) return;
|
if (!$('#autoRefresh').checked) return;
|
||||||
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
const sec = parseInt($('#intervalSel').value, 10) || 15;
|
||||||
window.__timer = setInterval(loadDashboard, sec * 1000);
|
timer = setInterval(refreshBoard, sec * 1000);
|
||||||
|
}
|
||||||
|
/* 取数参数:公开访问时后端按 class 裁剪并返回单班结果,取数必须带班级;
|
||||||
|
已登录时这个参数被忽略,留着不影响。 */
|
||||||
|
function dashParams(extra){
|
||||||
|
const p = {};
|
||||||
|
for (const k in (extra || {})) p[k] = extra[k];
|
||||||
|
p.projectId = p.projectId || $('#projectSel').value || CUR.pid || '';
|
||||||
|
if (IS_CLASS && CUR.cls) p.class = CUR.cls;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
async function refreshBoard(manual){
|
||||||
|
const pid = $('#projectSel').value;
|
||||||
|
if (!pid) return;
|
||||||
|
// 标签页在后台时不做自动轮询(省流量、也避免无人值守时反复请求卷王)
|
||||||
|
if (!manual && document.hidden) return;
|
||||||
|
const btn = $('#btnRefresh');
|
||||||
|
if (manual && btn) { btn.disabled = true; btn.textContent = '刷新中…'; }
|
||||||
|
try {
|
||||||
|
const fresh = await api('dashboard', dashParams({ projectId: pid }));
|
||||||
|
DASH = fresh;
|
||||||
|
// 项目集合可能换:刷新班级下拉但保留当前选择
|
||||||
|
const sel = $('#classSel');
|
||||||
|
const keep = sel.value;
|
||||||
|
const classes = (fresh.classes || []).map(c => c.class);
|
||||||
|
if (!classes.includes(keep)) {
|
||||||
|
sel.innerHTML = classes.length
|
||||||
|
? classes.map(c => '<option value="' + esc(c) + '">' + esc(c) + '</option>').join('')
|
||||||
|
: '<option value="">暂无班级</option>';
|
||||||
|
sel.disabled = !classes.length;
|
||||||
|
if (classes.length) { sel.value = classes[0]; CUR.cls = classes[0]; writeUrl({ cls: classes[0] }); }
|
||||||
|
}
|
||||||
|
// 刷新前捕获各行位置,刷新后做 FLIP:新数据行滑入、其它行下移
|
||||||
|
const flipPrev = captureFlip($('#boardBody'));
|
||||||
|
renderBoard();
|
||||||
|
playFlip($('#boardBody'), flipPrev);
|
||||||
|
$('#lastUpdate').textContent = fresh.updated_at || '-';
|
||||||
|
markLive();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.auth) { handleAuthFail(e); return; }
|
||||||
|
// 静默续期:保留旧数据,但把实时点变红提示中断
|
||||||
|
markStale(e.message);
|
||||||
|
} finally {
|
||||||
|
if (manual && btn) { btn.disabled = false; btn.textContent = '刷新'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 后退锁定:单班大屏不允许退回首屏 / 主系统 ----------
|
||||||
|
* 做法:加载后立刻 pushState 一条同页历史,之后每次 popstate(无论用户按后退还是前进)
|
||||||
|
* 都再推回一条,页面永远留在当前 URL 上——浏览器后退键因此无效。
|
||||||
|
* 只锁 IS_CLASS 形态:首页仍可正常后退(例如退回登录前的主系统页)。 */
|
||||||
|
let historyLocked = false;
|
||||||
|
function lockHistory(){
|
||||||
|
if (!IS_CLASS || historyLocked) return;
|
||||||
|
historyLocked = true;
|
||||||
|
const push = () => { try { history.pushState({ nyLock: 1 }, '', location.href); } catch (e) {} };
|
||||||
|
push();
|
||||||
|
window.addEventListener('popstate', function onPop(e){
|
||||||
|
// 落回自己推入的同页条目时不再重复占位,避免连按后退无限堆积历史
|
||||||
|
if (e.state && e.state.nyLock) return;
|
||||||
|
push();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 事件 ---------- */
|
/* ---------- 事件 ---------- */
|
||||||
$('#projectSel').addEventListener('change', () => { backToOverview(); loadDashboard(); });
|
$('#projectSel').addEventListener('change', () => { CUR.pid = $('#projectSel').value; CUR.cls = ''; refreshClassSel(); schedule(); });
|
||||||
$('#btnRefresh').addEventListener('click', loadDashboard);
|
$('#classSel').addEventListener('change', () => { CUR.cls = $('#classSel').value; renderBoard(); });
|
||||||
$('#autoRefresh').addEventListener('change', schedule);
|
$('#btnRefresh').addEventListener('click', () => refreshBoard(true));
|
||||||
$('#intervalSel').addEventListener('change', schedule);
|
$('#autoRefresh').addEventListener('change', () => {
|
||||||
|
writeUrl({ auto: $('#autoRefresh').checked });
|
||||||
|
LS.set(LS_DASH_AUTO, $('#autoRefresh').checked);
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
$('#intervalSel').addEventListener('change', () => {
|
||||||
|
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
$('#btnFullscreen').addEventListener('click', toggleFullscreen);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
document.addEventListener('webkitfullscreenchange', onFsChange);
|
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||||||
$('#btnBack').addEventListener('click', backToOverview);
|
|
||||||
|
|
||||||
// 班级卡片点击进入详情
|
/* 切回前台:立刻补一次数据并重排定时器;切到后台:停掉轮询 */
|
||||||
$('#classGrid').addEventListener('click', e => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
const card = e.target.closest('.class-card');
|
if (document.hidden) {
|
||||||
if (card && card.dataset.cls) showClassDetail(card.dataset.cls);
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
} else {
|
||||||
|
refreshBoard();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loadProjects().then(loadDashboard);
|
/* ---------- 偏好恢复(自动刷新开关 + 间隔,含 ?interval= 深链) ---------- */
|
||||||
schedule();
|
function restoreDashPrefs(){
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
if (qs.get('interval')) {
|
||||||
|
$('#intervalSel').value = qs.get('interval');
|
||||||
|
LS.set(LS_DASH_INTERVAL, parseInt($('#intervalSel').value, 10) || 15);
|
||||||
|
} else {
|
||||||
|
const iv = LS.get(LS_DASH_INTERVAL, 0);
|
||||||
|
if (iv) {
|
||||||
|
const opts = Array.prototype.slice.call($('#intervalSel').options).map(o => parseInt(o.value, 10));
|
||||||
|
if (opts.includes(iv)) $('#intervalSel').value = String(iv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (CUR.auto !== '0') {
|
||||||
|
const auto = LS.get(LS_DASH_AUTO, null);
|
||||||
|
if (auto !== null) $('#autoRefresh').checked = !!auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 启动 ---------- */
|
||||||
|
restoreDashPrefs();
|
||||||
|
loadProjects().then(() => {
|
||||||
|
if (CUR.pid) {
|
||||||
|
$('#projectSel').value = CUR.pid;
|
||||||
|
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
||||||
|
} else if (IS_CLASS && $('#projectSel').value) {
|
||||||
|
// 公开链接可能不带 pid:自动落到该卷第一份考试,别让访客对着空页面
|
||||||
|
CUR.pid = $('#projectSel').value;
|
||||||
|
refreshClassSel().then(() => { renderBoard(); schedule(); });
|
||||||
|
} else {
|
||||||
|
refreshClassSel();
|
||||||
|
}
|
||||||
|
// 首页文案指向"选考试 → 进班级",避免顶栏停在旧的单班提示上
|
||||||
|
if (!IS_CLASS) {
|
||||||
|
const _hs = document.getElementById('heroSub');
|
||||||
|
if (_hs) _hs.textContent = '先选择一份问卷 / 考试,再点击班级卡片进入该班大屏';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lockHistory();
|
||||||
|
// auto 参数
|
||||||
|
if (CUR.auto === '0') $('#autoRefresh').checked = false;
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { defineConfig } from 'vitepress'
|
||||||
|
|
||||||
|
// 站点部署在域名的 /ny-exam-platform 子路径下,故 base 默认为该路径。
|
||||||
|
// 本地预览时用 DOCS_BASE=/ 覆盖:DOCS_BASE=/ npm run docs:build
|
||||||
|
const base = process.env.DOCS_BASE || '/ny-exam-platform/'
|
||||||
|
// 输出目录。默认 .vitepress/dist;若需要保留旧产物(或环境禁止删除目录),
|
||||||
|
// 可用 DOCS_OUT=.vitepress/dist2 npm run docs:build 输出到新目录。
|
||||||
|
const outDir = process.env.DOCS_OUT || '.vitepress/dist'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base,
|
||||||
|
outDir,
|
||||||
|
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/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: '切换到深色模式'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 接口一览
|
||||||
|
|
||||||
|
所有接口统一走 `api.php?action=<name>`,响应为 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`、
|
||||||
|
`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`、`class` | 按班级 / 组别聚合的看板数据。`class` 非空且未登录时,服务端只回这一个班(并标记 `public`) |
|
||||||
|
|
||||||
|
`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[] 未匹配答卷
|
||||||
|
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 | 方法 | 参数 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `export` | GET | `projectId`、`nameQid`、`cls`、`grp`、`mode=latest\|all`、`scoreMode=latest\|best`、`split=cls`、`sort`、`cols`、`skipTemp=1`、`noAbsent=1` | 成绩明细 `.xlsx`(`all` 时每人每次考试一行) |
|
||||||
|
| `export_absent` | GET | `projectId`、`nameQid`、`cls`、`grp` | 缺考名单 `.xlsx` |
|
||||||
|
| `export_multi` | GET | `projectIds`(逗号分隔)、`nameQid`、`mode=block\|summary\|both`、`wide=1`、`passTimes`、`markBest=0`、`passRow=0`、`scoreMode=latest\|highest`、`cls` / `grp`(可多值)、`withCls` / `withGrp`、`includeTemp`、`format=json` | 「每次考试」块表 / 宽表 / 成绩对比 `.xlsx`(`format=json` 时返回装配结果供前端打印) |
|
||||||
|
| `draw_export` | GET | `type=import\|list` | 白名单导入模板 / 白名单列表 |
|
||||||
|
|
||||||
|
`sort` 取值:`time` / `sid` / `score_desc` / `score_asc`。
|
||||||
|
|
||||||
|
### 导出新参数
|
||||||
|
|
||||||
|
| 参数 | 适用接口 | 取值 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `mode=all` | `export` | 缺省 | 每次考试一行(同一学号考 N 次就有 N 行);`mode=latest` 为每人一行最新成绩 |
|
||||||
|
| `scoreMode=best` | `export` | 缺省 | 每人一行的代表成绩改取**最高成绩**(默认 `latest` = 最近一次真实提交);`mode=all` 不生效 |
|
||||||
|
| `noAbsent=1` | `export`、`export_multi` | 缺省 | 不附缺考名单工作表(`export_multi` 口径:所选考试全都没交的人) |
|
||||||
|
| `skipTemp=1` | `export` | 缺省 | `mode=all` 时排除暂存草稿;`includeTemp=1` 可强制保留 |
|
||||||
|
| `mode=block` | `export_multi` | **默认** | 两行表头的「每次考试」块表:一列「第几次」被所有考试共用(逐行 1、2、3…,不合并),每场考试占 **1 列**(第 1 行考试名 + 第 2 行「成绩」小标题),学生块末尾一行「合格次数」,学号·姓名纵向合并,没考的考试合并成「未考」(旧值 `merged` / `attempts` 等价) |
|
||||||
|
| `wide=1` | `export_multi` | — | 只传 1 个 `projectId` 时改用宽表:每人一行、第几次直接做列名(`学号 / 姓名 / 第1次…第N次 / [合格次数]`),由 `ny_assemble_attempt_wide_sheet()` 生成 |
|
||||||
|
| `passTimes` | `export_multi` | 1 | 「合格次数」达标阈值:该场达到合格线的次数 ≥ 此值才标绿(默认 1) |
|
||||||
|
| `markBest=0` | `export_multi` | 开 | `=0` 取消「最高分标红」 |
|
||||||
|
| `passRow=0` | `export_multi` | 开 | `=0` 不输出「合格次数」行(也就没有绿底) |
|
||||||
|
| `mode=summary` | `export_multi` | — | 成绩对比:一行一个学生、一列一场考试(旧值 `matrix` 等价) |
|
||||||
|
| `mode=both` | `export_multi` | — | 第一张块表 + 第二张成绩对比表 |
|
||||||
|
| `format=json` | `export_multi` | — | 不下载文件,`ny_ok` 返回 `sheets`(`rows` / `merges` / `widths` / `header_rows` / `cell_styles`)+ `meta`(考试名、场次、班级 / 组别、列开关、`pass_line` / `pass_times` / `mark_best` / `pass_row` / `wide`),供「多考试打印」渲染 |
|
||||||
|
| `cls` / `grp` | `export_multi` | 新增 | 支持多选(逗号分隔),空 = 全部;与单考试 `export` 的筛选口径一致 |
|
||||||
|
| `withCls` / `withGrp` | `export_multi` | 新增 | 强制出 / 不出班级、组别列;缺省由 `ny_multi_col_flags()` 判定:选 ≥2 个班级(组别)才出,只选 1 个班级且未选组别两列都不出 |
|
||||||
|
| `includeTemp=1` | `export_multi` | 缺省 | 不传 = 块表默认跳过暂存草稿(保留时标注 `第N次(草稿)`) |
|
||||||
|
|
||||||
|
块表由 `ny_assemble_block_sheet()` 生成,返回 `rows`(两行表头 + 学生块)/ `merges` / `widths` / `header_rows=2`
|
||||||
|
/`cell_styles`;宽表由 `ny_assemble_attempt_wide_sheet()` 生成,参数同上。
|
||||||
|
合格线按**每场各自**的 `ny_resolve_score_cfg($pid, $c)` 解析(本卷满分 × 合格率),不是全场统一一条线。
|
||||||
|
|
||||||
|
`cell_styles` 是 `行,列 → red | green`(行与列均 **1-based**),只有两类标记:
|
||||||
|
|
||||||
|
- `red` —— 该场该生成绩 = 该场最高分(同分只标最早达成的那次);
|
||||||
|
- `green` —— 该场该生成绩达到合格线的次数 ≥ `passTimes`(未达标不标记)。
|
||||||
|
|
||||||
|
`XlsxWriter::renderMulti()` 支持 `merges`(1-based 闭区间 `[r1,c1,r2,c2]`)、`header_rows`(多行表头同样式)
|
||||||
|
与 `cell_styles`(单元格级红字 / 绿底);被合并的非首格写成「空值 + 同样式」以保证合并区域四周边框完整,
|
||||||
|
并跟随左上角首格的标记,避免绿底只覆盖半个格子。
|
||||||
|
前端 `sheetToHtml()` 把同一份 `merges` 换算成 `colspan` / `rowspan`、把 `cell_styles` 换算成红字 / 绿底,
|
||||||
|
所以**打印与导出的 Excel 用同一套判定**。
|
||||||
|
列显示规则 `ny_multi_col_flags()` 与排序比较函数同样放在 `lib/multi_export.php`(纯函数,可单测)。
|
||||||
|
|
||||||
|
## 前端调用约定
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 读
|
||||||
|
const d = await api('data', { params: { projectId, force: 1 } })
|
||||||
|
// 写:api() 会自动注入 _csrf
|
||||||
|
await api('roster_save', { json: { students } })
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [架构与目录](./architecture.md)
|
||||||
|
- [测试](./testing.md)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 架构与目录
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**:PHP 8(过程式 + 少量静态类),零 Composer 依赖
|
||||||
|
- **前端**:原生 HTML / CSS / JavaScript(无框架、无构建步骤)
|
||||||
|
- **存储**:SQLite(PDO),缺失 `pdo_sqlite` 时自动降级为 JSON 文件
|
||||||
|
- **外部依赖**:仅卷王(SurveyKing)开放 API
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ny-exam-platform/
|
||||||
|
├─ index.php 主系统页面(服务端渲染骨架 + 前端挂载)
|
||||||
|
├─ dashboard.php 数据大屏:首页(班级入口)+ ?class= 单班大屏(锁后退)
|
||||||
|
├─ 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 防惊群)
|
||||||
|
│ ├─ 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/ 导入临时目录(解析后即删)
|
||||||
|
├─ 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)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 数据存储(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/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)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 测试
|
||||||
|
|
||||||
|
回归测试分两类:**PHP 自测**(纯逻辑断言)与 **Node e2e**(起 mock 服务 + PHP 内置服务器,走真实 HTTP)。
|
||||||
|
|
||||||
|
## 一键运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会依次执行:
|
||||||
|
|
||||||
|
| 文件 | 类型 | 覆盖 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/test-aggregate.php` | PHP | 聚合、去重、考试次数、排序 |
|
||||||
|
| `tests/test-new-features.php` | PHP | 再次提交置顶排序、XLSX 多表、名单导入列识别 |
|
||||||
|
| `tests/test-config-persist.php` | PHP | 配置双写兜底(DB / `config.json`) |
|
||||||
|
| `tools/test-xlsxwriter.php` | PHP | XLSX 写入与 ZIP 打包、合并单元格、单元格级红字 / 绿底 |
|
||||||
|
| `tests/test-multi-export.php` | PHP | 多考试块表与单场宽表的装配(合并区间、合格线、标红标绿、班级 / 组别列) |
|
||||||
|
| `tools/test-config-save.js` | Node e2e | 配置保存、连接变化检测、每卷配置隔离、CSRF |
|
||||||
|
| `tools/test-upload-guard.js` | Node e2e | 导入文件后缀白名单 |
|
||||||
|
| `tools/test-whitelist.js` | Node e2e | 白名单导入 / 名单识别 |
|
||||||
|
| `tools/test-login-loop.js` | Node e2e | 未配置账号 → 登录 → 已登录状态回流 |
|
||||||
|
| `tools/test-dashboard-nav.js` | Node e2e | 数据大屏两种形态:`dash-home` / `dash-class` 骨架、顶栏控件收敛、后退锁定、班级卡片渲染 |
|
||||||
|
| `tools/test-draw-alloc.js` | Node e2e | 随机抽取名额分配(加权 / 自定义 / 守恒 fuzz) |
|
||||||
|
| `tools/test-block-print.js` | Node(不需要服务) | 从 `assets/app.js` 抽取 `sheetToHtml` 校验合并区间换算与红 / 绿标记 |
|
||||||
|
|
||||||
|
> `tools/test-block-print.js`、`tools/make-samples.js`、`tools/test-dashboard-nav.js`、`tools/make-dash-sample.js`
|
||||||
|
> 都会**直接从生产源码里抽取函数在真数据上执行**,而不是另抄一份渲染逻辑——改了这些函数,这几处会立刻暴露漂移。
|
||||||
|
|
||||||
|
::: warning 端口占用会让 e2e「假通过 / 假失败」
|
||||||
|
每个 e2e 段用固定端口(`test-config-save.js` 18081、`test-whitelist.js` 18082、`test-upload-guard.js` 18083 等)。
|
||||||
|
若上一次跑测试时中断,PHP 子进程可能变成**孤儿服务**继续占着端口:新一次运行时它的 `waitPort` 会照常通过
|
||||||
|
(打到的是旧服务),于是登录直接失败、报错信息指向错误的连接地址。遇到"服务已就绪但登录失败",先查端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
netstat -ano | grep LISTENING | grep -E ":180[0-9][0-9]|:199[0-9][0-9]"
|
||||||
|
taskkill //PID <pid> //F
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 生成导出版式样例
|
||||||
|
|
||||||
|
想直观看新版式(以及验证打印与导出一致),可生成可打开的样例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tools/make-samples.php # 出两版 xlsx + sheet-data.json
|
||||||
|
node tools/make-samples.js # 用生产 sheetToHtml 渲染打印预览 HTML
|
||||||
|
|
||||||
|
node tools/make-dash-sample.js # 渲染数据大屏首页预览(含班级入口卡片)
|
||||||
|
```
|
||||||
|
|
||||||
|
产物在 `docs/samples/`:打印预览 `preview-multi-block.html` / `preview-single-wide.html`
|
||||||
|
(+ 对应 `.xlsx`),大屏首页预览 `dash-home-preview.html`。两者都自起 mock 服务与临时数据目录,
|
||||||
|
不会读写项目 `data/`。
|
||||||
|
|
||||||
|
可通过环境变量覆盖运行时路径:
|
||||||
|
|
||||||
|
```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,需手工核对
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 写新测试时注意
|
||||||
|
|
||||||
|
| 陷阱 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 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/` 为空
|
||||||
|
- [ ] 工具栏「导出」只一个按钮 → 弹窗里四种内容都能出表,学号不是科学计数法
|
||||||
|
- [ ] 成绩明细「每人成绩」切 最高 / 最新 → 成绩列跟着变,文件名带 `_最高成绩` / `_最新成绩`
|
||||||
|
- [ ] 每次考试导出 → 最高分红字、合格次数达标绿底、未达标留白、整场未考合并成「未考」;每场考试只占 1 列,「第几次」逐行 1/2/3 不被合并
|
||||||
|
- [ ] 多考试打印 → 版式与导出的 Excel 一致,「合格次数」标签没被合并吞掉
|
||||||
|
- [ ] 打印预览 → 默认成绩降序,没有教师签字栏、没有站点域名页脚;**绿底 / 红字真的印出来了**
|
||||||
|
(打印样式带 `print-color-adjust: exact`,不需要在对话框里勾「背景图形」)
|
||||||
|
- [ ] 导出弹窗「附加内容 · 附缺考名单」→ 每次考试 / 多考试对比也出「缺考名单」工作表
|
||||||
|
- [ ] 在每卷配置里改满分成数 / 合格率 → 数据大屏成员榜的红绿跟着变
|
||||||
|
- [ ] 数据大屏首页 → 选试卷后列出班级卡片,点击进入单班大屏
|
||||||
|
- [ ] 单班大屏 → 顶栏只有试卷 / 自动刷新 / 全屏,没有班级选择与退出;按浏览器后退键**退不回去**
|
||||||
|
- [ ] 复制单班大屏链接在**无痕窗口**打开 → 直接看到该班数据(免登录),顶栏有「公开 · 无需登录」角标;
|
||||||
|
看不到其它班级的成绩;大屏首页仍要求登录
|
||||||
|
- [ ] 数据大屏全屏进入 / 退出都正常
|
||||||
|
- [ ] 随机抽取 → 名额守恒
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [架构与目录](./architecture.md)
|
||||||
|
- [接口一览](./api.md)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 数据大屏
|
||||||
|
|
||||||
|
数据大屏(`dashboard.php`)是**面向投屏 / 大屏**的汇总视图,适合考试进行中实时监控提交进度。
|
||||||
|
它有两种形态,由 URL 决定:
|
||||||
|
|
||||||
|
| 形态 | 打开方式 | 长什么样 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **首页(班级入口)** | 从主页面顶部导航 **📊 数据大屏** 打开,或直接访问 `dashboard.php` | 先选试卷 / 考试,下方以卡片列出该卷的所有班级;点卡片进入该班大屏 |
|
||||||
|
| **单班大屏** | 点首页的班级卡片(等价于 `dashboard.php?pid=<问卷ID>&class=<班级名>`) | 只展示这一个班:5 张汇总卡 + 分组对比 / 成员榜 |
|
||||||
|
|
||||||
|
首页是"入口",不是概览页——它不堆指标,只做一件事:让你**一次看到有哪些班,点进去看单个班**。
|
||||||
|
|
||||||
|
## 单班大屏是公开的,可以直接发链接
|
||||||
|
|
||||||
|
`dashboard.php?pid=<问卷ID>&class=<班级名>` **不需要登录**,任何人打开都能看到这一个班的数据
|
||||||
|
(顶栏带「公开 · 无需登录」角标)。适合直接把链接发到班级群让孩子或家长自己看。
|
||||||
|
|
||||||
|
::: danger 公开的是"这一个班",不是全卷
|
||||||
|
接口在**服务端**先按 `class` 把名单和答卷裁剪掉,再回传数据:公开链接拿不到全卷汇总,
|
||||||
|
也拿不到其它班级的成绩。想看别的班,打开那个班自己的链接即可;不带 `class` 参数的
|
||||||
|
大屏首页仍然要求登录。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: warning 单班大屏没有回头路
|
||||||
|
单班大屏**不显示**班级下拉、「← 主系统」和「退出」,页面内部也不存在回到首页的链接;
|
||||||
|
浏览器**后退键同样被屏蔽**(页面加载后立刻 `pushState` 占位,任何 `popstate` 都再推回去)。
|
||||||
|
想回主系统或换班,请在地址栏改 URL,或关闭标签页重新从首页进入。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 顶部控件
|
||||||
|
|
||||||
|
| 控件 | 首页 | 单班大屏 |
|
||||||
|
| --- | :-: | :-: |
|
||||||
|
| 问卷 / 考试 | ✅ | ✅ 切换后若该班不在新卷名单里,会自动落到第一个班 |
|
||||||
|
| 班级 | ✅ | ❌ 由 URL 固定,不提供切换 |
|
||||||
|
| 自动刷新开关 + 间隔 | ✅ | ✅ |
|
||||||
|
| 刷新 | ✅ | ✅ |
|
||||||
|
| ⛶ 全屏 | ✅ | ✅ |
|
||||||
|
| ← 主系统 | ✅ | ❌ 由 CSS 隐藏 |
|
||||||
|
| 退出 | ✅ | ❌ 页面里不再渲染 |
|
||||||
|
|
||||||
|
::: tip 后台标签页会自动暂停
|
||||||
|
切换到其他标签页后看板会**停止轮询**,切回来时立即补拉一次数据并重新开始计时——
|
||||||
|
投屏时长开也不会在无人观看时反复请求卷王。自动刷新失败时,「最后更新」会显示
|
||||||
|
**刷新中断(仍显示上次数据)**,鼠标悬停可看到具体原因。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 首页:班级入口卡片
|
||||||
|
|
||||||
|
每张卡片给出该班的速览,点整张卡片(或聚焦后按回车)进入该班大屏:
|
||||||
|
|
||||||
|
`总人数 / 已提交 / 缺考 / 平均分 / 合格率` + 一条提交率进度条;没交过卷的班也能一眼看到缺考数。
|
||||||
|
|
||||||
|
::: tip 首页也在自动刷新
|
||||||
|
首页同样受自动刷新开关控制(默认 15 秒),卡片上的数字会跟着变,适合把整层楼/整个年级的
|
||||||
|
提交进度投在一块屏上。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 汇总卡片(单班大屏)
|
||||||
|
|
||||||
|
`总人数 / 已提交 / 缺考 / 平均分 / 合格率(≥X%满分)`
|
||||||
|
|
||||||
|
合格率卡片的阈值标签会随 [每卷配置](./project-config.md) 实时变化。
|
||||||
|
|
||||||
|
## 成绩的红绿怎么定的
|
||||||
|
|
||||||
|
成员榜(以及「刷 N 次」下钻弹窗)里的成绩颜色**完全按 [每卷配置](./project-config.md) 里
|
||||||
|
管理员设的满分与合格率**判定,不是写死的:
|
||||||
|
|
||||||
|
| 占满分的比例 | 颜色 |
|
||||||
|
| --- | --- |
|
||||||
|
| ≥ 90%(合格线本身高过 90% 时则以合格线为准) | 🟢 绿 |
|
||||||
|
| ≥ 合格率 × 满分(合格但未达优秀) | 🟡 黄 |
|
||||||
|
| < 合格率 × 满分(不及格) | 🔴 红 |
|
||||||
|
|
||||||
|
例如把本卷满分改成 150、合格率改成 80%,那么 100 分只占 66.7% → 判红,130 分(86.7%)判黄,
|
||||||
|
145 分(96.7%)判绿。鼠标悬停在分数上会显示「占满分 xx% 合格线 xx 分」。
|
||||||
|
|
||||||
|
## 班级 / 组别下钻
|
||||||
|
|
||||||
|
(即单班大屏的主体部分)
|
||||||
|
|
||||||
|
看板主体按**班级**分块,每块内再按**组别**对比,指标与统计口径完全相同:
|
||||||
|
总人数、已提交、缺考、提交率、平均分、合格率、最高分、最低分、平均用时。
|
||||||
|
|
||||||
|
点击考过多次的成员(带「刷 N 次」标签)可查看其**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||||
|
按 `Esc` 或点击弹窗外部可关闭。
|
||||||
|
|
||||||
|
> 未分班的人归入「(未分班)」,未分组的人归入「(未分组)」,不会丢失。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 全屏按钮点了没反应
|
||||||
|
|
||||||
|
- 浏览器要求全屏必须由**用户手势**触发,脚本自动调用 `requestFullscreen()` 会被拒绝;
|
||||||
|
- 退出全屏调用的是 `document.exitFullscreen()`(注意不是 `document.documentElement`),
|
||||||
|
平台已修正过这一点;
|
||||||
|
- 若页面被嵌在 `<iframe>` 中,需要给 iframe 加 `allowfullscreen` 属性。
|
||||||
|
|
||||||
|
### 数据不更新
|
||||||
|
|
||||||
|
确认自动刷新开关打开;若结果缓存时间较长,点 **刷新** 会带 `force=1` 跳过缓存。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [成绩明细与统计口径](./scores.md)
|
||||||
|
- [系统设置](./settings.md)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 随机抽取
|
||||||
|
|
||||||
|
随机抽取(`draw.php`)用于从名单中抽人——抽查、抽考、抽签、抽取上台展示等。
|
||||||
|
|
||||||
|
从主页面顶部导航 **🎲 随机抽取** 打开(新标签页)。
|
||||||
|
|
||||||
|
## 抽取来源
|
||||||
|
|
||||||
|
| 来源 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 学号名单(全部) | 从完整名单中抽 |
|
||||||
|
| 某试卷已提交人员 | 只从交过卷的人里抽(需要先选试卷) |
|
||||||
|
|
||||||
|
可再用 **班级 / 组别** 下拉缩小候选池。
|
||||||
|
|
||||||
|
## 抽取人数
|
||||||
|
|
||||||
|
输入框直接填,或用快捷按钮 `1` / `5` / `10` / `全部`。
|
||||||
|
|
||||||
|
## 分组比例
|
||||||
|
|
||||||
|
决定有分组时名额如何分配:
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 按人数加权(默认) | 人多的组分到更多名额,采用**最大余额法**,保证总数守恒 |
|
||||||
|
| 各组均分(5:5) | 名额均分到组(不是按人数加权),余数给排在前面的小组 |
|
||||||
|
| 自定义比例 | 为每个组手动指定比例 |
|
||||||
|
| 不分组 | 忽略组别,整池随机 |
|
||||||
|
|
||||||
|
::: tip 5:5 的含义
|
||||||
|
「各组均分」是**名额均分到组**,不等于每组抽到的人数与其人数成正比。
|
||||||
|
如果你想"人多的班多抽几个",请用默认的**按人数加权**。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 去重与排除
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 排除已抽中 | 本次会话内已抽中的人不再被抽到;**重置结果**后清零 |
|
||||||
|
| 排除管理 | 手动勾选不参与抽取的人(如请假、已抽过);支持清空排除 |
|
||||||
|
|
||||||
|
## 结果区
|
||||||
|
|
||||||
|
顶部卡片:`候选人数 / 本次抽取 / 累计已抽取 / 已排除 / 抽取来源`。
|
||||||
|
|
||||||
|
结果表格:`序号 / 学号 / 姓名 / 班级 / 组别 / 登陆账号 / 操作`。
|
||||||
|
|
||||||
|
「登陆账号」列让你在导出前就能核对:显示为 `学号 ⚠` 表示名单里没填这一列,
|
||||||
|
导出时会自动回退用学号——**这会导致学生登不进考试**,请先回主系统「名单管理」补全。
|
||||||
|
|
||||||
|
::: tip 「全部」按钮会跟随候选池
|
||||||
|
点 **全部** 后抽取人数进入跟随模式:切换班级 / 组别 / 来源时人数自动更新为当前
|
||||||
|
候选池大小。手工改动人数输入框即解除该模式。
|
||||||
|
:::
|
||||||
|
|
||||||
|
| 按钮 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 📜 历史 | 查看历次抽取批次。**重置结果不会清空历史** |
|
||||||
|
| 📋 复制名单 | 复制姓名 / 登陆账号,可直接粘贴到微信或 Excel |
|
||||||
|
| ⬇ 白名单导入模板 | 导出为卷王白名单导入模板 `.xlsx` |
|
||||||
|
| ⬇ 白名单列表 | 导出为白名单列表 `.xlsx` |
|
||||||
|
| 重置结果 | 清空本次结果与累计已抽取(历史保留) |
|
||||||
|
| 清空历史 | 清空全部历史记录 |
|
||||||
|
|
||||||
|
## 登陆账号说明
|
||||||
|
|
||||||
|
名单中的**登陆账号**列在这里既用于导出白名单,也会直接显示在结果表格中(便于导出前核对)。
|
||||||
|
若名单里没有这一列,抽取结果里会用 `学号 ⚠` 标出,导出时回退用学号。
|
||||||
|
|
||||||
|
::: warning 模板文件名
|
||||||
|
卷王模板中该列表头为「登陆账号」(不是"登录账号"),导出时已按此命名。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [名单管理](./roster.md)
|
||||||
|
- [系统设置](./settings.md)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 导出与打印
|
||||||
|
|
||||||
|
工具栏下方只有一个 **导出** 按钮,所有导出与打印的选项都收在这个弹窗里,不再散落多个小按钮。
|
||||||
|
选项在你打开弹窗前就配好了会**自动记住**(存本地),下次打开就是上次的设置。
|
||||||
|
|
||||||
|
> 生成的是 `.xlsx`:零依赖,仓库内自行实现 XLSX 写入,没有 `zip` 扩展也能用。
|
||||||
|
|
||||||
|
## 导出弹窗
|
||||||
|
|
||||||
|
| 分区 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| **导出内容** | 成绩明细 / 每次考试 / 缺考名单 / 多考试对比(四选一,选中后下方分区跟着变) |
|
||||||
|
| **统计范围** | 班级 / 组别多选(不选 = 全部,按住 `Ctrl` / `⌘` 多选) |
|
||||||
|
| **成绩口径与列** | 每人成绩(最新 / 最高)、排序、出表方式、按班级分工作表、附缺考名单 |
|
||||||
|
| **多考试对比** | 表格形态、对比成绩、勾选要对比的考试 |
|
||||||
|
| **附加内容** | 附缺考名单(成绩明细 / 每次考试 / 多考试对比 都出工作表) |
|
||||||
|
| **标记与合格次数** | 合格次数阈值、最高分标红、输出「合格次数」行 |
|
||||||
|
| **打印设置** | 纸张方向、打印标题 |
|
||||||
|
|
||||||
|
- 分区随「导出内容」显隐:选 **缺考名单** 时只保留范围与打印设置;
|
||||||
|
选 **多考试对比** 时才出现考试勾选表;
|
||||||
|
- **附加内容** 与 **标记与合格次数** 是三类成绩表共用的,不随导出内容消失;
|
||||||
|
「按班级分工作表」只有成绩明细支持(置灰不可勾选);
|
||||||
|
- 弹窗底部有**一句话汇总**(导什么、哪些考试、多大范围),导错了一眼能看出来;
|
||||||
|
- 打印走隐藏 iframe 调起,**不会被浏览器弹窗拦截挡掉**,也不会多开标签页。
|
||||||
|
|
||||||
|
### 成绩明细:每人最新成绩 / 最高成绩
|
||||||
|
|
||||||
|
**默认按每人最新成绩显示**(最近一次真实提交),可选 **最高成绩**(该生在这些考试里的最高分)。
|
||||||
|
|
||||||
|
| 选项 | 效果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 每人成绩 | `最新成绩`(默认)/ `最高成绩` —— 每人一行时的代表成绩 |
|
||||||
|
| 排序 | `按提交时间` / `按学号` / `按成绩降序` / `按成绩升序` |
|
||||||
|
| 出表方式 | `每人一行(汇总)`(默认)/ `每次考试一行(明细)`:明细模式保留每位学生的历史提交 |
|
||||||
|
| 附缺考名单 | 默认开;只导成绩时取消勾选(接口 `noAbsent=1`)。**每次考试 / 多考试对比 也支持**,口径为「名单里有、所选考试全都没交」 |
|
||||||
|
| 按班级分工作表 | 一个班级一个工作表(xlsx 多工作表) |
|
||||||
|
|
||||||
|
默认列:`学号 / 姓名 / 班级 / 组别 / 成绩 / 最高分 / 考试次数 / 最近提交时间 / 考试时长 / 考试时长(秒)`。
|
||||||
|
|
||||||
|
## 「每次考试」块表(多场对比,主推格式)
|
||||||
|
|
||||||
|
勾选多场考试,选 **两行表头** 形态。**一列「第几次」被所有考试共用**(逐行写 1、2、3…,不合并),
|
||||||
|
每场考试占 **1 列**,学生块末尾多一行 **合格次数**。
|
||||||
|
|
||||||
|
| 学号 | 姓名 | 第几次 | 第一次月考·数学 | 第一次月考·英语 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| | | | 成绩 | 成绩 |
|
||||||
|
| (合并) | (合并) | 1 | 112 | 130 |
|
||||||
|
| (合并) | (合并) | 2 | 100 | 129 |
|
||||||
|
| (合并) | (合并) | 3 | **136** | **133** |
|
||||||
|
| (合并) | (合并) | **合格次数** | 3 | 3 |
|
||||||
|
| 356002 | 李四 | 1 | (合并)**未考** | 128 |
|
||||||
|
| (合并) | (合并) | **合格次数** | | 1 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- **学生块高度** = 该生在所有勾选考试里最多的那次次数 + 1(末行是合格次数);
|
||||||
|
- **学号 / 姓名**按学生块纵向合并;**第几次**逐行写各自的次序号(1、2、3…),**不合并**
|
||||||
|
——合并会把 2、3 吞掉只剩 1;
|
||||||
|
- **每场考试占 1 列**:第 1 行是考试名、第 2 行是 `成绩` 小标题;
|
||||||
|
- **没考的考试**(整场没有该生记录)→ 该列 × 学生块高合并成一个 **`未考`** 格子;
|
||||||
|
- **排序**:学生按学号升序(出现班级 / 组别列时按 班级 → 组别 → 学号);
|
||||||
|
- **暂存草稿**:默认不导出、也不占「第几次」编号。
|
||||||
|
|
||||||
|
### 单场考试的宽表版式
|
||||||
|
|
||||||
|
只导出 **一场** 考试时,改用更清楚的宽表:每人一行,第几次直接做列名。
|
||||||
|
|
||||||
|
| 学号 | 姓名 | 第1次 | 第2次 | 第3次 | 合格次数 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 356001 | 张三 | 112 | 100 | **136** | 3 |
|
||||||
|
| 356002 | 李四 | **128** | | | 1 |
|
||||||
|
|
||||||
|
导出与打印用的是同一套判定,所以单场和「多场块表」的标记完全一样。
|
||||||
|
|
||||||
|
## 红 / 绿标记
|
||||||
|
|
||||||
|
| 标记 | 条件 | 效果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **最高分标红** | 该场该生成绩 = 该场最高分 | 红字加粗(同分只标最早达成的那次) |
|
||||||
|
| **合格次数标绿** | 该场该生达到合格线的次数 ≥ 阈值 | 绿底加粗 |
|
||||||
|
| 合格次数未达标 | 未达阈值 | **仍显示实际次数**(含 0 次),只不标绿 |
|
||||||
|
|
||||||
|
- 合格线 = 该场**本卷满分 × 合格率**(每个试卷各自的配置,不是全场统一一条线);
|
||||||
|
- 阈值在弹窗里可改(默认 1 次);关掉「最高分标红」或「合格次数行」就只出表不标记;
|
||||||
|
- 整场没考的不参与合格次数判定,直接是 `未考`;
|
||||||
|
- 学号等长数字列统一按**文本格式**写入,不会被 Excel 转成科学计数法,也不会被当公式执行;
|
||||||
|
- 打印与导出的 Excel 用同一份服务端装配结果(红字 / 绿底也来自同一份 `cell_styles`),
|
||||||
|
不存在「导出一个样、打印另一个样」。
|
||||||
|
|
||||||
|
## 班级 / 组别列什么时候出现
|
||||||
|
|
||||||
|
| 选择情况 | 班级列 | 组别列 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 只选 1 个班级、未选组别 | 不出 | 不出 |
|
||||||
|
| 选 ≥2 个班级 | **出** | 未选组别时按数据判断(数据跨多组才出) |
|
||||||
|
| 只选 1 个班级但选 ≥2 个组别 | 不出 | **出** |
|
||||||
|
| 什么都不选(= 全部) | 数据跨 ≥2 个班级才出 | 数据跨 ≥2 个组别才出 |
|
||||||
|
|
||||||
|
- 出列时**连续相同班级 / 组别的单元格会纵向合并**(顺序按 班级 → 组别 → 学号),一个班只占一格;
|
||||||
|
- 也可以用接口参数 `withCls=1` / `withGrp=1` 强制出列(`=0` 强制不出)。
|
||||||
|
|
||||||
|
### 其它形态
|
||||||
|
|
||||||
|
| 表格形态 | 产出 |
|
||||||
|
| --- | --- |
|
||||||
|
| 两行表头(每次考试 + 合格次数) | 上面那套块表(**默认**) |
|
||||||
|
| 成绩对比(每人一行) | 一行一个学生、一列一场考试,成绩取「最新」或「最高」 |
|
||||||
|
| 两者都出 | 第一张块表 + 第二张成绩对比表 |
|
||||||
|
|
||||||
|
### 文件名
|
||||||
|
|
||||||
|
```
|
||||||
|
成绩明细_<试卷名>[_<班级>][_<组别>][_最新成绩|_最高成绩]_<YYYYMMDD_HHMMSS>.xlsx
|
||||||
|
每次考试_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
|
||||||
|
缺考名单_<试卷名>[_<班级>][_<组别>]_<YYYYMMDD_HHMMSS>.xlsx
|
||||||
|
多考试成绩对比_<N>场[_<班级…>][_<组别…>]_<最新成绩|最高分>_<YYYYMMDD_HHMMSS>.xlsx
|
||||||
|
每次考试与成绩对比_<N>场[_<班级…>][_<组别…>]_<YYYYMMDD_HHMMSS>.xlsx # 两者都出
|
||||||
|
```
|
||||||
|
|
||||||
|
### 跟随筛选
|
||||||
|
|
||||||
|
导出**完全跟随页面当前的班级 / 组别筛选**,缺考口径也与页面一致
|
||||||
|
(筛选后名单 − 筛选后已提交,按学号匹配),不会出现「页面上 30 人、导出 50 人」的情况。
|
||||||
|
|
||||||
|
## 打印
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 打印标题 | 默认按考试名自动生成,可改成学校 / 考试正式名称 |
|
||||||
|
| 纸张方向 | 多考试打印默认 **横向**(列多不折行),可改纵向 |
|
||||||
|
| 成绩明细的分组对比 | 按班级或组别分段,段首给出小计(人数 / 平均 / 最高 / 最低 / 合格人数与合格率) |
|
||||||
|
| 成绩明细的显示选项 | 显示名次、显示「是否合格」列、显示考试次数、附缺考名单 |
|
||||||
|
|
||||||
|
- 表头在**每一页自动重复**,行不会被切断到两页;两行表头都放进 `<thead>`;
|
||||||
|
- 页眉含范围(班级 / 组别)、打印时间、人数、平均分、最高 / 最低、合格人数与合格线;
|
||||||
|
- 页脚含**教师 / 教研组长签字栏与日期**,可直接签字归档。
|
||||||
|
|
||||||
|
成绩明细面板右上角的**打印**与导出弹窗里的打印设置是同一套(打印标题、方向、分组对比都在弹窗里改)。
|
||||||
|
|
||||||
|
::: tip 绿底 / 红字要能印出来
|
||||||
|
打印样式里写了 `print-color-adjust: exact`,**不需要**在打印对话框里手动勾选「背景图形」,
|
||||||
|
Chrome / Edge 会照常输出绿底与红字。若仍看不到底纹,检查是否换用了不支持该属性的浏览器。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: tip 缺考名单单独成页
|
||||||
|
勾选「附缺考名单」后,缺考名单工作表会在打印时**另起一页**(不会挤在成绩表底部),便于分开归档。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 直接调用导出接口
|
||||||
|
|
||||||
|
需要对接其它系统时,可以带参数直接 GET(需已登录):
|
||||||
|
|
||||||
|
```
|
||||||
|
# 成绩明细(每人一行最新 / 最高)
|
||||||
|
api.php?action=export&projectId=xxx&cls=1班,2班&grp=A组&mode=latest&scoreMode=best&sort=score_desc
|
||||||
|
# 「每次考试」块表(多场)
|
||||||
|
api.php?action=export_multi&projectIds=a,b&mode=block&passTimes=1
|
||||||
|
# 单场宽表版式(每人一行,第几次做列名)
|
||||||
|
api.php?action=export_multi&projectIds=a&mode=block&wide=1
|
||||||
|
# 多考试:成绩对比 / 两者都出
|
||||||
|
api.php?action=export_multi&projectIds=a,b&mode=summary&scoreMode=highest
|
||||||
|
api.php?action=export_multi&projectIds=a,b&mode=both
|
||||||
|
# 「多考试打印」用的数据(不下载文件,返回 rows / merges / header_rows / cell_styles)
|
||||||
|
api.php?action=export_multi&projectIds=a,b&mode=block&format=json
|
||||||
|
# 缺考名单
|
||||||
|
api.php?action=export_absent&projectId=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 取值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `projectId` | 试卷 ID | 必填(`export_multi` 用 `projectIds`,逗号分隔,可只传一场) |
|
||||||
|
| `cls` / `grp` | 班级 / 组别 | 与页面筛选一致;`export_multi` 支持**多选**(逗号分隔),空 = 全部 |
|
||||||
|
| `mode` | `latest` / `all` | `export` 的成绩明细:每人一行(默认,取最新)/ 每次考试一行 |
|
||||||
|
| `scoreMode` | `latest` / `best` | 每人一行的代表成绩:最新(默认)/ 最高 |
|
||||||
|
| `mode`(`export_multi`) | `block` / `summary` / `both` | 每次考试块表(默认)/ 成绩对比 / 两者都出;旧值仍兼容:`matrix`→`summary`、`merged`·`attempts`→`block` |
|
||||||
|
| `wide` | `1` | 单场 `block` 时改用宽表版式(每人一行、第几次做列名) |
|
||||||
|
| `passTimes` | 正整数 | 合格次数达标阈值(默认 1) |
|
||||||
|
| `markBest` | `0` | `=0` 取消「最高分标红」 |
|
||||||
|
| `passRow` | `0` | `=0` 不输出「合格次数」行(也就没有绿底) |
|
||||||
|
| `format` | `json` | 不下载文件,返回 `sheets`(`rows` / `merges` / `header_rows` / `cell_styles`)供打印渲染 |
|
||||||
|
| `withCls` / `withGrp` | `1` / `0` | 强制出 / 不出班级、组别列;缺省按上表规则自动判断 |
|
||||||
|
| `noAbsent` | `1` | 成绩明细不附缺考名单 |
|
||||||
|
| `skipTemp` | `1` | `mode=all` 时排除暂存草稿 |
|
||||||
|
| `includeTemp` | `1` | 每次考试块表保留暂存草稿(标注为 `第N次(草稿)`) |
|
||||||
|
| `sort` | `time` / `sid` / `score_desc` / `score_asc` | 成绩明细排序(块表恒按 班级 → 组别 → 学号 / 学号) |
|
||||||
|
| `split` | `cls` | 按班级拆分多工作表 |
|
||||||
|
| `cols` | 逗号分隔列名 | 从默认列中挑选(仅 `export`) |
|
||||||
|
|
||||||
|
`format=json` 返回的 `cell_styles` 是 `行,列 → red | green`,行与列都是 **1-based**;
|
||||||
|
前端据此加红字 / 绿底,合并区间内的非首格继承左上角首格的标记。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [成绩明细与统计口径](./scores.md)
|
||||||
|
- [接口一览](../dev/api.md)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 常见问题
|
||||||
|
|
||||||
|
## 连接与登录
|
||||||
|
|
||||||
|
**Q:提示"无法连接卷王系统"**
|
||||||
|
|
||||||
|
依次检查:
|
||||||
|
|
||||||
|
1. 卷王地址与端口是否正确、是否带多余的路径或结尾 `/`;
|
||||||
|
2. 服务器防火墙 / 安全组是否放行;
|
||||||
|
3. 接口前缀是否为 `/api`(企业版可能是 `/admin-api`);
|
||||||
|
4. 账号密码是否正确;
|
||||||
|
5. 在**平台所在服务器本机**执行 `curl http://127.0.0.1:19911/api/system` 验证卷王本身是否可达。
|
||||||
|
|
||||||
|
**Q:自己写的客户端登录成功却返回 401 "Full authentication is required to access this resource"**
|
||||||
|
|
||||||
|
卷王社区版 v1.13 的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**。
|
||||||
|
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
|
||||||
|
`Cookie: sk-token=<token>`。本平台已处理此细节。
|
||||||
|
|
||||||
|
**Q:登录一直失败**
|
||||||
|
|
||||||
|
登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。
|
||||||
|
若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。
|
||||||
|
|
||||||
|
**Q:卷王改了密码后平台一直提示未登录**
|
||||||
|
|
||||||
|
到 **设置** 里重新填写密码并保存(连接信息变化会自动清除本地令牌并重新登录)。
|
||||||
|
|
||||||
|
## 匹配与数据
|
||||||
|
|
||||||
|
**Q:读到了答卷但学号为空**
|
||||||
|
|
||||||
|
说明答卷里的姓名与名单不一致:
|
||||||
|
|
||||||
|
- 到 **姓名字段** 下拉里手动指定正确的题目;
|
||||||
|
- 或到 **设置 → 姓名题关键词** 里补充关键词;
|
||||||
|
- **未匹配答卷** 面板会列出这些人,可据此核对(常见原因是姓名前后有空格、用了别名)。
|
||||||
|
|
||||||
|
**Q:成绩列全是 `-`**
|
||||||
|
|
||||||
|
该问卷不是考试模式,或没有开启自动评分,卷王未返回 `examScore`。
|
||||||
|
|
||||||
|
**Q:考试时长全是 `-`**
|
||||||
|
|
||||||
|
答卷的 `metaInfo.answerInfo` 中没有开始 / 结束时间(例如后台手动录入的答卷)。
|
||||||
|
|
||||||
|
**Q:同一个人交了多次,看板里的成绩取哪次?**
|
||||||
|
|
||||||
|
由 **设置 → 同一学号去重** 决定代表成绩取「最新」还是「最高」。
|
||||||
|
但**考试次数始终是全部提交次数**,点击成员可查看每一次提交。
|
||||||
|
|
||||||
|
**Q:我想让某个学生不计入某张试卷的缺考**
|
||||||
|
|
||||||
|
用 [试卷设置 → 免考名单](./project-config.md#免考名单),只对该试卷生效,
|
||||||
|
不要从名单里删除(否则其它试卷也会少人)。
|
||||||
|
|
||||||
|
**Q:合格率是按 60 分算吗?**
|
||||||
|
|
||||||
|
不是写死的 60 分,而是「成绩 ≥ 满分 × 合格率阈值」。阈值全局默认 60%,
|
||||||
|
可在 [试卷设置](./project-config.md) 里为单张试卷覆盖。卡片标题会显示当前阈值。
|
||||||
|
|
||||||
|
**Q:切换班级后数字变了,是不是重新请求了服务器?**
|
||||||
|
|
||||||
|
没有。切班级是本地即时重算,不额外打卷王。
|
||||||
|
|
||||||
|
## 性能
|
||||||
|
|
||||||
|
**Q:人多的时候页面卡 / 后台刷新失败**
|
||||||
|
|
||||||
|
已内置以下优化:结果缓存(默认 15 秒)、虚拟滚动、搜索防抖、页面切到后台自动暂停刷新、
|
||||||
|
分页大小夹在 20–200。仍有问题时可适当调大「结果缓存(秒)」并调小刷新频率。
|
||||||
|
|
||||||
|
**Q:后台刷新报错 "Allowed memory size exhausted"**
|
||||||
|
|
||||||
|
拉取大量答卷时 PHP 内存不足。程序已把单页条数夹在 20–200,并在入口尝试把
|
||||||
|
`memory_limit` 提到 512M。若主机用 `php_admin_value memory_limit` 硬锁,
|
||||||
|
`ini_set` 会静默失效,需要改 `php.ini` 或 FPM 池配置。
|
||||||
|
|
||||||
|
## 存储与迁移
|
||||||
|
|
||||||
|
**Q:数据存在哪?**
|
||||||
|
|
||||||
|
启用 `pdo_sqlite` 时存在 `data/ny.db`(表 `ny_settings` / `ny_roster` / `ny_project_config`);
|
||||||
|
未启用时回落到 `data/config.json`、`data/roster.json`、`data/project_config.json`。
|
||||||
|
两者始终双写,切换不会丢数据。
|
||||||
|
|
||||||
|
**Q:可以把平台搬到另一台服务器吗?**
|
||||||
|
|
||||||
|
可以。整个目录复制过去,并把 `data/` 一起带上(里面有配置、名单、每卷配置),确保 `data/` 可写。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
**Q:上传的名单文件会被别人访问到吗?**
|
||||||
|
|
||||||
|
不会。上传文件落在 `data/uploads/`,扩展名白名单(`.xlsx` / `.xlsm` / `.csv` / `.txt`)、
|
||||||
|
随机文件名,且**解析完成后立刻删除**。另外请确保按
|
||||||
|
[部署加固清单](../security/hardening.md) 在 Web 服务器上屏蔽 `/data/` 目录。
|
||||||
|
|
||||||
|
**Q:为什么配置文件能被直接下载?**
|
||||||
|
|
||||||
|
说明 `/data/` 没有被屏蔽。Apache 下仓库会自动生成 `.htaccess`;
|
||||||
|
**nginx 不读 `.htaccess`**,必须显式加:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /data/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [部署加固清单](../security/hardening.md)。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 环境要求与部署
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
| 项目 | 要求 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PHP | ≥ 8.0 | 推荐 8.1+ |
|
||||||
|
| `curl` | 必需 | 调用卷王 API |
|
||||||
|
| `json` / `mbstring` | 必需 | 数据编解码、中文处理 |
|
||||||
|
| `openssl` | 推荐 | 登录时对密码做 RSA 加密;缺失时降级为明文提交 |
|
||||||
|
| `zip` | 可选 | 缺失时用内置解析器读取 xlsx(写入 xlsx 内置实现无需此扩展) |
|
||||||
|
| `pdo_sqlite` | 推荐 | 启用 SQLite 存储;缺失时自动降级为 JSON 文件存储 |
|
||||||
|
| 数据库 | 无 | 不需要 MySQL / PostgreSQL |
|
||||||
|
| Composer | 无 | 没有任何第三方依赖 |
|
||||||
|
|
||||||
|
> 用 `php -m` 可以查看已加载的扩展。
|
||||||
|
|
||||||
|
## 部署方式
|
||||||
|
|
||||||
|
### 方式一:PHP 内置服务器(体验 / 内网临时使用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ny-exam-platform
|
||||||
|
php -S 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开 <http://localhost:8080>。Windows 下也可双击 `启动平台.bat`。
|
||||||
|
|
||||||
|
::: warning 仅用于内网体验
|
||||||
|
PHP 内置服务器是单进程、无并发能力的开发服务器,**不要**用于生产。
|
||||||
|
并且它**不解析 `.htaccess`**,`data/` 目录会可被直接访问,请务必参考
|
||||||
|
[安全加固清单](../security/hardening.md)。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 方式二:Nginx + PHP-FPM(推荐生产)
|
||||||
|
|
||||||
|
假设站点根目录为 `/www/sites/sj.exam.nxxy335.top/index`,那么把仓库内容放在该目录下即可。
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name sj.example.com;
|
||||||
|
root /www/sites/sj.exam.nxxy335.top/index;
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
charset utf-8;
|
||||||
|
client_max_body_size 20m; # 名单文件导入上限,与后端 20MB 校验一致
|
||||||
|
|
||||||
|
# ---- 安全:禁止直接访问数据目录(nginx 不读 .htaccess,必须显式配置)----
|
||||||
|
location ^~ /data/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 不要暴露隐藏文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
校验并重载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:Apache
|
||||||
|
|
||||||
|
仓库会在 `data/` 及其子目录自动生成 `.htaccess`(内容 `Deny from all`),
|
||||||
|
Apache 下 `data/` 默认即被保护。仍需确认:
|
||||||
|
|
||||||
|
- 虚拟主机 `AllowOverride` 不为 `None`(否则 `.htaccess` 被忽略);
|
||||||
|
- `mod_rewrite` 若启用,确认没有规则把 `/data/` 放行。
|
||||||
|
|
||||||
|
### 方式四:宝塔面板
|
||||||
|
|
||||||
|
1. 新建站点 → 根目录指向仓库目录;
|
||||||
|
2. PHP 版本选 8.0+,安装 `fileinfo`、`opcache`(可选);
|
||||||
|
3. 站点 → 配置文件,在 `server {}` 内加入上面的 `location ^~ /data/` 段;
|
||||||
|
4. 站点 → 目录权限,确保 `data/` 可写(运行用户通常为 `www`);
|
||||||
|
5. 若使用反向代理,确认 `/api.php` 的 `action` 参数未被缓存。
|
||||||
|
|
||||||
|
## 目录权限
|
||||||
|
|
||||||
|
| 路径 | 需要 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data/` | 可写 | 配置、名单、令牌的根目录 |
|
||||||
|
| `data/ny.db` | 可写 | SQLite 主库(启用 `pdo_sqlite` 时) |
|
||||||
|
| `data/uploads/` | 可写 | 导入名单时的临时落盘,解析后立即删除 |
|
||||||
|
| `data/cache/` | 可写 | 卷王令牌缓存、结果缓存 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown -R www:www data
|
||||||
|
chmod -R 755 data
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 下给 `IIS_IUSRS` 或站点应用程序池身份赋予 `data` 的「修改」权限。
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
仓库是纯文件部署,升级即覆盖文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖后 `data/` 保留(其中存放配置与名单,并且在 `.gitignore` 中),
|
||||||
|
程序会在首次访问时自动完成目录初始化与数据迁移。
|
||||||
|
|
||||||
|
::: tip 关于 SQLite 迁移
|
||||||
|
首次在有 `pdo_sqlite` 的环境运行时,`data/config.json` 与 `data/roster.json`
|
||||||
|
会被自动迁移进 `data/ny.db`,之后以数据库为准,文件作为备份继续双写。
|
||||||
|
迁移是幂等的,重复执行不会覆盖已入库的数据。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [快速上手](./quickstart.md)
|
||||||
|
- [安全加固清单](../security/hardening.md)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 平台简介
|
||||||
|
|
||||||
|
NY 问卷考试系统数据平台是一个**零第三方运行依赖**的 PHP 8 应用,直接对接
|
||||||
|
[卷王问卷系统(SurveyKing)社区版 v1.13](https://gitee.com/surveyking/surveyking) 的开放 API。
|
||||||
|
|
||||||
|
它不改变卷王本身的任何数据,只读拉取答卷,然后在本地做「姓名 → 学号」关联、
|
||||||
|
统计聚合、导出与展示。
|
||||||
|
|
||||||
|
## 它能解决什么问题
|
||||||
|
|
||||||
|
卷王自带的考试统计偏"问卷视角",而实际教学/培训场景通常需要的是"名单视角":
|
||||||
|
|
||||||
|
- 谁**没考**(而不是谁考了)—— 需要拿完整名单去比对;
|
||||||
|
- 某个班 / 某个组的成绩分布如何 —— 需要按班级、组别切片;
|
||||||
|
- 同一个人考了多次,该看哪一次 —— 需要去重策略 + 完整的考试次数;
|
||||||
|
- 不同试卷满分不同 —— 需要每卷独立的满分与合格率;
|
||||||
|
- 成绩要交给教务系统 —— 需要干净的 `.xlsx` 导出;
|
||||||
|
- 要抽查 / 抽考 —— 需要可去重、可留痕的随机抽取。
|
||||||
|
|
||||||
|
## 功能总览
|
||||||
|
|
||||||
|
### 主系统 `index.php`
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 试卷切换 | 下拉选择卷王中的问卷 / 考试,自动拉取该卷全部答卷 |
|
||||||
|
| 姓名关联 | 自动识别答卷中的「姓名」题,也可手动指定字段;未匹配答卷单独面板 + 一键补入名单 |
|
||||||
|
| 统计卡片 | 名单总人数、已提交、缺考、平均分、最高分、最低分、合格率、平均用时;选择班级后全部联动 |
|
||||||
|
| 成绩明细 | 虚拟滚动(无分页)、点击表头排序、搜索(提示同步显示命中数)、考试次数、新提交绿色高亮(30 秒自动清除) |
|
||||||
|
| 缺考名单 | 名单中有但未提交的人,支持复制学号 / 姓名、打印(按班级分组) |
|
||||||
|
| 试卷设置 | 为当前选中的试卷单独设置满分 / 合格率 / 免考名单,试卷间互相隔离 |
|
||||||
|
| 导出 / 打印 | 成绩明细、缺考名单 `.xlsx`;成绩明细按成绩降序打印,支持分组小计、合格标记、附缺考名单、签字栏 |
|
||||||
|
|
||||||
|
### 数据大屏 `dashboard.php`
|
||||||
|
|
||||||
|
首页先选试卷,再用卡片列出该卷的班级;点卡片进入该班专属大屏。
|
||||||
|
大屏页支持全屏切换与自动刷新,**没有返回按钮,浏览器后退键也被屏蔽**(只能靠地址栏或关标签页离开)。
|
||||||
|
|
||||||
|
### 随机抽取 `draw.php`
|
||||||
|
|
||||||
|
从「学号名单」或「某试卷已提交人员」中随机抽取,支持:
|
||||||
|
|
||||||
|
- 按班级 / 组别筛选;
|
||||||
|
- 名额分配四种模式:按人数加权(默认)、各组均分、自定义比例、不分组;
|
||||||
|
- 排除已抽中(本次会话内去重)、排除管理(手动指定不参与的人);
|
||||||
|
- 历史批次留痕、白名单导入模板 / 白名单列表导出。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- **零 Composer 依赖**:不使用任何 PHP 第三方库,XLSX 读写、ZIP 打包均由仓库内代码完成;
|
||||||
|
- **不修改卷王数据**:全程只读(登录 + 项目列表 + 答卷列表 + 答卷详情);
|
||||||
|
- **可降级**:没有 `zip` 扩展时用内置解析器读 xlsx;没有 `pdo_sqlite` 时自动回落到 JSON 文件存储;
|
||||||
|
- **单目录部署**:整个目录复制到站点目录即可,只需保证 `data/` 可写。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [环境要求与部署](./install.md)
|
||||||
|
- [快速上手](./quickstart.md)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 试卷设置(满分 / 合格率 / 免考)
|
||||||
|
|
||||||
|
不同试卷满分往往不同(100 分制、150 分制、50 道题每题 2 分……),合格率要求也可能不同。
|
||||||
|
所以**满分的每卷配置放在主页面「试卷设置」里,而不是系统设置**——设置里只保留全局默认值。
|
||||||
|
|
||||||
|
## 打开方式
|
||||||
|
|
||||||
|
主页面工具栏 → **试卷设置**(按钮为高亮主色)。配置**只对当前选中的试卷生效**。
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
|
||||||
|
```
|
||||||
|
本卷配置 > 全局默认(系统设置)
|
||||||
|
```
|
||||||
|
|
||||||
|
未设置某项时显示"跟随全局(X)",保存后卡片标题与统计口径立即更新。
|
||||||
|
|
||||||
|
## 满分
|
||||||
|
|
||||||
|
- 用于合格率阈值计算和卡片展示;
|
||||||
|
- 留空或填 `0` = **清除本卷配置**,回落全局默认;
|
||||||
|
- 全局默认满分在 **设置 → 全局默认满分** 中修改。
|
||||||
|
|
||||||
|
## 合格率
|
||||||
|
|
||||||
|
- 百分比,含义是「成绩 ≥ 满分 × X%」记合格。例如满分 150、合格率 60 → 90 分及格;
|
||||||
|
- 留空 = 跟随全局;录入范围会被夹到 `1 ~ 100`;
|
||||||
|
- 保存后统计卡片标题会变成 `合格率 (≥60%满分)`,与实际口径一致。
|
||||||
|
|
||||||
|
::: tip 为什么不再写死 60%
|
||||||
|
早期版本把「≥60% 满分」硬编码在代码里,一旦满分不是 100 就会产生误导。
|
||||||
|
现在阈值可配、可每卷覆盖,且界面上直接显示当前阈值。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 免考名单
|
||||||
|
|
||||||
|
某些人因故不参加本卷考试(缓考、免修、已单独补考等),如果直接把他们从名单里删除,
|
||||||
|
其它试卷的统计也会受影响。免考名单解决的就是这个:
|
||||||
|
|
||||||
|
- 填写**学号**列表,支持逗号、中文逗号、空格、换行分隔;
|
||||||
|
- 生效范围:仅当前试卷;
|
||||||
|
- 效果:这些人**不计入名单总人数,也不计入缺考**,彻底从本卷的分母中移除;
|
||||||
|
- 若他们实际上也交了卷,答卷仍会出现在成绩明细中。
|
||||||
|
|
||||||
|
示例输入:
|
||||||
|
|
||||||
|
```
|
||||||
|
2024012001, 2024012002
|
||||||
|
2024012015
|
||||||
|
```
|
||||||
|
|
||||||
|
## 清除本卷设置
|
||||||
|
|
||||||
|
弹窗左下角 **清除本卷设置** 会删除该试卷的全部独立配置(满分、合格率、免考),
|
||||||
|
之后完全跟随全局。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
| 环境 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 启用 `pdo_sqlite` | `data/ny.db` 表 `ny_project_config`(主键 `project_id`) |
|
||||||
|
| 未启用 | `data/project_config.json` |
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [系统设置](./settings.md)(全局默认值)
|
||||||
|
- [成绩明细与统计口径](./scores.md)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 快速上手
|
||||||
|
|
||||||
|
从零到看到第一行成绩,一共四步。
|
||||||
|
|
||||||
|
## 第 1 步:登录
|
||||||
|
|
||||||
|
浏览器打开站点根路径,会直接进入登录页。
|
||||||
|
|
||||||
|
::: info 本平台没有独立账号体系
|
||||||
|
登录时填写的**就是卷王后台的管理员账号与密码**——平台用这组账密向卷王换取令牌。
|
||||||
|
连接信息(卷王地址、接口前缀)已在 `data/config.json` 中预置,登录页只要求账号与密码。
|
||||||
|
:::
|
||||||
|
|
||||||
|
- 账号:卷王后台管理员账号(默认带出配置中的值,可现场改)
|
||||||
|
- 密码:卷王后台密码
|
||||||
|
|
||||||
|
登录成功后账密会写回配置并持久化,这样令牌失效时平台能自动重新登录。
|
||||||
|
连续失败 5 次会锁定 10 分钟。
|
||||||
|
|
||||||
|
## 第 2 步:配置连接信息
|
||||||
|
|
||||||
|
右上角 **设置** → 填写:
|
||||||
|
|
||||||
|
| 配置项 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不要带 `/` |
|
||||||
|
| 接口前缀 | `/api` | 社区版默认 `/api` |
|
||||||
|
| 登录账号 / 密码 | — | 保存后不会回显密码,留空表示不修改 |
|
||||||
|
| 姓名题关键词 | `姓名,名字,学生姓名` | 用于自动识别哪道题填的是姓名 |
|
||||||
|
| 全局默认满分 | `100` | **每套试卷的满分请在主页面「试卷设置」里单独设** |
|
||||||
|
| 全局默认合格率 | `60` | 百分比,60 表示"≥满分的 60%"为合格 |
|
||||||
|
| 校验 HTTPS 证书 | 勾选 | 自签名证书可取消勾选 |
|
||||||
|
| 默认刷新间隔 | `15` | 秒 |
|
||||||
|
|
||||||
|
先点 **测试连接**,出现「连接成功」后再点 **保存**。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::: warning 保存时机
|
||||||
|
只有**连接信息**(地址 / 前缀 / 账号 / 密码 / 证书校验)发生变化时,保存才会清除本地令牌并重新登录卷王。
|
||||||
|
修改满分、合格率、布局等其它项不会打断当前数据刷新。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 第 3 步:导入学号名单
|
||||||
|
|
||||||
|
右上角 **名单管理 <b>0</b>** → 点击或拖拽文件 → 选择 **覆盖 / 追加** → **保存名单**。
|
||||||
|
|
||||||
|
名单文件支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,至少包含:
|
||||||
|
|
||||||
|
| 学号 | 姓名 | 班级(可选) | 组别(可选) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2023001 | 张三 | 1班 | A |
|
||||||
|
| 2023002 | 李四 | 1班 | B |
|
||||||
|
|
||||||
|
- 表头名称不严格要求,程序按关键词识别(如「学号 / 学籍号 / 编号」均可);
|
||||||
|
- **班级**列存在时,成绩明细会多出班级列,并支持按班级筛选与联动统计;
|
||||||
|
- **组别**列用于分组打印与随机抽取的分组比例;
|
||||||
|
- 也可以直接在弹窗里 **+ 新增** 逐条录入,勾选后 **删除选中**(二次确认)。
|
||||||
|
|
||||||
|
| 安全说明 |
|
||||||
|
| --- |
|
||||||
|
| 上传文件会先落盘到 `data/uploads/` 后立即解析再删除,扩展名白名单 + 随机文件名,不会残留可供 URL 直连访问的文件。详见 [安全总览](../security/)。 |
|
||||||
|
|
||||||
|
## 第 4 步:选择试卷,开始看数据
|
||||||
|
|
||||||
|
回到主页面,左上角 **问卷 / 考试** 下拉选择目标试卷,数据会自动拉取并按设定的间隔刷新。
|
||||||
|
|
||||||
|
- **姓名字段**默认为「自动识别」,若学号大面积为空,可手动指定正确的题目;
|
||||||
|
- 顶部 **班级 / 组别** 下拉可切片,统计卡片会同步联动;
|
||||||
|
- 新提交的答卷整行绿色高亮,30 秒后自动清除,也可手动「清除高亮」;
|
||||||
|
- 若某份试卷满分不是 100,点 **试卷设置** 单独配置。
|
||||||
|
|
||||||
|
## 常见问题速查
|
||||||
|
|
||||||
|
| 现象 | 排查方向 |
|
||||||
|
| --- | --- |
|
||||||
|
| 提示"无法连接卷王系统" | 地址 / 端口 / 防火墙 / 接口前缀 / 账密;在服务器本机 `curl http://127.0.0.1:1991/api/system` 验证 |
|
||||||
|
| 学号列为空 | 姓名不一致(空格、别名);手动指定姓名字段;在设置中补充关键词 |
|
||||||
|
| 成绩全是 `-` | 该问卷不是考试模式或未开启自动评分,卷王未返回 `examScore` |
|
||||||
|
| 时长全是 `-` | 答卷 `metaInfo.answerInfo` 中没有起止时间(例如后台手动录入) |
|
||||||
|
|
||||||
|
完整列表见 [常见问题](./faq.md)。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 名单管理
|
||||||
|
|
||||||
|
名单是平台的"参照系":所有统计都以名单为分母,缺考名单 = 名单中有、但未提交的人。
|
||||||
|
|
||||||
|
## 打开方式
|
||||||
|
|
||||||
|
主页面右上角 **名单管理 <b>N</b>**(N 为当前人数)。也可以在随机抽取页点击 **名单管理** 跳转回来。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 导入文件
|
||||||
|
|
||||||
|
支持 `.xlsx` / `.xlsm` / `.csv` / `.txt`,单次上限 20 MB。
|
||||||
|
|
||||||
|
1. 点击上传区或把文件拖进来;
|
||||||
|
2. 选择 **覆盖**(替换现有名单)或 **追加**(合并到现有名单);
|
||||||
|
3. 点 **保存名单**。
|
||||||
|
|
||||||
|
> 导入后弹窗内会立即显示解析结果,此时**尚未落库**,检查无误再点保存。
|
||||||
|
|
||||||
|
### 列识别规则
|
||||||
|
|
||||||
|
程序先找表头行:某一行同时命中「学号类」与「姓名类」关键词,就把它当作表头,从下一行开始读数据。
|
||||||
|
|
||||||
|
| 字段 | 识别关键词(正则,容错空格) | 缺失时 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 学号 | `学号` `编号` `序号` `学员号` `号码` `no` `id` | 取第 0 列 |
|
||||||
|
| 姓名 | `姓名` `名字` `学生姓名` `姓氏` `成员` | 取第 1 列(若学号占了第 0 列) |
|
||||||
|
| 班级 | `班级` `class` | 留空,界面不显示该列 |
|
||||||
|
| 组别 | `组别` `分组` `组次` `组` `group` | 留空 |
|
||||||
|
| 登陆账号 | `登陆账号` `登录账号` `账号` `手机号` `手机` | 留空 |
|
||||||
|
|
||||||
|
其它说明:
|
||||||
|
|
||||||
|
- 学号会自动去掉 Excel 数字尾巴(如 `2024012001.0` → `2024012001`);
|
||||||
|
- 同一学号重复出现时保留首次,其余记为「重复跳过」;
|
||||||
|
- 学号与姓名均为空的行记为「跳过」;
|
||||||
|
- CSV 自动识别并剥离 BOM,非 UTF-8 编码(GBK 等)会自动转码。
|
||||||
|
|
||||||
|
### 示例文件
|
||||||
|
|
||||||
|
| 学号 | 姓名 | 班级 | 组别 | 登陆账号 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 2024012001 | 张三 | 1班 | A | stu001 |
|
||||||
|
| 2024012002 | 李四 | 1班 | B | stu002 |
|
||||||
|
| 2024012003 | 王五 | 2班 | A | stu003 |
|
||||||
|
|
||||||
|
**登陆账号** 只用于随机抽取页导出「白名单导入模板 / 白名单列表」,界面任何位置都不显示。
|
||||||
|
|
||||||
|
## 手动维护
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新增 | 点 **+ 新增** 弹出录入框,填学号 / 姓名 / 班级 / 组别 / 登陆账号 |
|
||||||
|
| 编辑 | 直接在表格里改单元格 |
|
||||||
|
| 删除 | 勾选行 → **删除选中** → 二次确认 |
|
||||||
|
| 清空 | **清空** → 二次确认,清空全部名单 |
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
**保存名单**是全量替换(后端 `DELETE` + `INSERT`),请确认弹窗内列表无误再保存。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 从未匹配答卷补入名单
|
||||||
|
|
||||||
|
主页面 **未匹配答卷** 面板列出"提交了但姓名不在名单中"的答卷。点 **加入名单**,
|
||||||
|
填写学号 / 班级 / 组别后即可补入,避免下次仍然匹配不上。
|
||||||
|
|
||||||
|
## 试卷级免考
|
||||||
|
|
||||||
|
只想让某几个人在**某一张试卷**中不计入统计?用 [试卷设置 → 免考名单](./project-config.md#免考名单),
|
||||||
|
而不是把他们从名单里删掉——免考只在当前试卷生效,不会影响其它试卷。
|
||||||
|
|
||||||
|
## 存储位置
|
||||||
|
|
||||||
|
| 环境 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 启用 `pdo_sqlite`(默认) | `data/ny.db` 表 `ny_roster` |
|
||||||
|
| 未启用 `pdo_sqlite` | `data/roster.json` |
|
||||||
|
|
||||||
|
首次在有 SQLite 的环境运行时,`roster.json` 会自动迁移进数据库(仅当库为空时)。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [成绩明细与统计口径](./scores.md)
|
||||||
|
- [试卷设置](./project-config.md)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 成绩明细与统计口径
|
||||||
|
|
||||||
|
这一页说明**每个数字到底是怎么算出来的**,以及表格里的每一列是什么含义。
|
||||||
|
|
||||||
|
## 统计卡片
|
||||||
|
|
||||||
|
| 卡片 | 计算方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 名单总人数 | 名单条目数;被当前试卷设为**免考**的人不计入 |
|
||||||
|
| 已提交 | 匹配到名单、且提交了答卷的人数(去重后的代表成绩行数) |
|
||||||
|
| 缺考 | `max(0, 名单总人数 − 已提交)` |
|
||||||
|
| 提交率 | `已提交 ÷ 名单总人数 × 100%`——考试进行中最该盯的指标 |
|
||||||
|
| 提交率 | `已提交 ÷ 名单总人数 × 100%` |
|
||||||
|
| 平均分 | 所有**有成绩**的答卷成绩平均值;无成绩者不计入分母 |
|
||||||
|
| 最高分 / 最低分 | 同上,取自所有有成绩的答卷 |
|
||||||
|
| 合格率 | `成绩 ≥ 满分 × 合格率阈值` 的人数 ÷ **已提交人数**。阈值全局默认 60%,可在 [试卷设置](./project-config.md) 针对单卷覆盖 |
|
||||||
|
| 平均用时 | 所有**有时长**的答卷时长平均值(秒),格式化为「X 分 Y 秒」 |
|
||||||
|
|
||||||
|
::: tip 卡片标题会跟着配置变
|
||||||
|
合格率卡片的标题会实时显示当前阈值,例如「合格率 (≥70%满分)」,避免你误以为还是 60%。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 班级 / 组别联动
|
||||||
|
|
||||||
|
选择顶部 **班级** 下拉后:
|
||||||
|
|
||||||
|
- 全部统计卡片只统计该班级;
|
||||||
|
- 成绩明细只显示该班级;
|
||||||
|
- 缺考名单只显示该班级,标题右侧计数同步变化;
|
||||||
|
- 切换班级是**本地即时重算**,不会额外请求服务器。
|
||||||
|
|
||||||
|
再选 **组别** 可做二次切片。
|
||||||
|
|
||||||
|
## 成绩明细列
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 学号 / 姓名 / 班级 / 组别 | 来自名单;未匹配到的答卷不会进入本表 |
|
||||||
|
| 成绩 | 卷王返回的 `examScore`;该问卷不是考试模式时为 `-` |
|
||||||
|
| 考试次数 | 该学生**全部提交次数**,与「去重」开关无关 |
|
||||||
|
| 提交时间 | **最近一次**提交时间(重考低分也会让这行按最新时间排序到最上面) |
|
||||||
|
| 考试时长 | 最近一次答卷的时长;后台手动录入等没有起止时间的答卷为 `-` |
|
||||||
|
|
||||||
|
点击任意一行可查看该学生的**每一次提交**(时间、成绩、时长),最佳成绩有标记。
|
||||||
|
|
||||||
|
### 成绩颜色
|
||||||
|
|
||||||
|
成绩按**占本卷满分的百分比**着色,而不是写死的 90 / 60 分:
|
||||||
|
|
||||||
|
| 颜色 | 条件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 绿色 | ≥ 满分的 90% |
|
||||||
|
| 橙色 | ≥ 合格率线(默认满分的 60%,可在[每卷配置](./project-config.md)里改) |
|
||||||
|
| 红色 | 低于合格率线 |
|
||||||
|
|
||||||
|
这样把满分改成 50 分或 20 分制时,不会因为绝对分数偏低而整列误标红。
|
||||||
|
数据看板的成员榜使用同一套规则。
|
||||||
|
|
||||||
|
## 排序与搜索
|
||||||
|
|
||||||
|
- 点击表头即可排序,支持学号 / 姓名 / 班级 / 组别 / 成绩 / 考试次数 / 提交时间 / 时长;
|
||||||
|
- 默认排序键是**最近提交时间**,所以刚交卷的人一定在最上面;
|
||||||
|
- 搜索框支持学号 / 姓名模糊匹配,200 ms 防抖;
|
||||||
|
- 成绩明细**无分页**,采用虚拟滚动,几千行也不卡;页面切到后台时自动暂停刷新。
|
||||||
|
|
||||||
|
## 新数据高亮
|
||||||
|
|
||||||
|
每次刷新时,与上一次相比新出现的提交(按学号判断)整行显示绿色底纹,
|
||||||
|
**30 秒后自动清除**;也可以点 **清除高亮** 立刻去掉。首屏加载不标记。
|
||||||
|
|
||||||
|
## 去重策略
|
||||||
|
|
||||||
|
设置里的「同一学号去重」只决定**代表成绩**取哪一份(最新 / 最高):
|
||||||
|
|
||||||
|
| 开关 | 成绩明细 | 考试次数 | 点击行内的历史 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 开启 | 一人一行 | 仍是全部次数 | 仍是全部提交 |
|
||||||
|
| 关闭 | 一人多行 | 仍是全部次数 | 仍是全部提交 |
|
||||||
|
|
||||||
|
`best_score` 字段始终是跨全部尝试的真实最高分。
|
||||||
|
|
||||||
|
## 未匹配答卷
|
||||||
|
|
||||||
|
提交了、但姓名不在名单中的答卷会单独成面板。常见原因:
|
||||||
|
|
||||||
|
- 姓名前后有空格、写了别名、用了繁体字;
|
||||||
|
- 姓名题选错了(到 **姓名字段** 下拉里手动指定);
|
||||||
|
- 名单里根本没有这个人。
|
||||||
|
|
||||||
|
可直接在面板里点 **加入名单** 补录。
|
||||||
|
|
||||||
|
## 缺考名单
|
||||||
|
|
||||||
|
名单中有、但未提交的人。支持 **复制学号**(逗号分隔)、**复制姓名**、**打印**。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [试卷设置(满分 / 合格率 / 免考)](./project-config.md)
|
||||||
|
- [导出与打印](./export-print.md)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 系统设置
|
||||||
|
|
||||||
|
点击右上角 **设置** 打开。设置保存到 `data/ny.db`(SQLite);无 `pdo_sqlite` 时落到 `data/config.json`,两者始终双写。
|
||||||
|
|
||||||
|
## 连接
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 卷王系统地址 | `https://exam.nxxy335.top` | 结尾不带 `/`,可直接在设置里修改 |
|
||||||
|
| 接口前缀 | `/api` | 社区版 `/api`;企业版可能用 `/admin-api`;可直接在设置里修改 |
|
||||||
|
| 登录账号 | `admin` | 卷王后台管理员账号 |
|
||||||
|
| 登录密码 | `123456` | 不回显;留空表示不修改 |
|
||||||
|
| 校验 HTTPS 证书 | 勾选 | 自签名证书取消勾选 |
|
||||||
|
| 超时(秒) | `20` | 单次 HTTP 超时 |
|
||||||
|
|
||||||
|
::: tip 登录流程(社区版 v1.13)
|
||||||
|
`GET /api/system` 取 RSA 公钥 → 公钥加密密码 → `POST /api/public/login` 换取 JWT。
|
||||||
|
|
||||||
|
注意 v1.13 服务端的 `JwtTokenFilter` **只从 Cookie `sk-token` 读取令牌,不读 `Authorization` 头**,
|
||||||
|
所以客户端会把登录响应 `Set-Cookie` 中的 `sk-token` 原样带在后续每个请求的 `Cookie` 头里
|
||||||
|
(同时带 `Authorization` 头以兼容旧版)。平台已内置该流程与令牌缓存,并向下兼容
|
||||||
|
`/system/auth/login`、`/admin-api/oauth2/token` 两种登录方式。
|
||||||
|
|
||||||
|
这也是"自己写的客户端明明拿到 JWT 却仍返回 401 *Full authentication is required*"的根因。
|
||||||
|
:::
|
||||||
|
|
||||||
|
点击 **测试连接** 会立即做一次登录 + 项目列表探测,返回明确的成功 / 失败原因。
|
||||||
|
|
||||||
|
::: warning 保存时会自动重新登录的情况
|
||||||
|
仅当 **地址、接口前缀、账号、密码、证书校验** 五项中任意一项发生变化时,保存才会清除本地令牌缓存
|
||||||
|
(`data/cache/token.json`)并重新登录卷王。其余项(满分、合格率、刷新间隔、布局等)保存不会打断刷新。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 姓名题关键词 | `姓名,名字,学生姓名,…` | 逗号分隔,用于自动识别答卷中的姓名题 |
|
||||||
|
| 全局默认满分 | `100` | 试卷未单独设置时使用;每卷满分在主页面「试卷设置」设置 |
|
||||||
|
| 全局默认合格率 | `60` | 百分比。60 表示「成绩 ≥ 满分 × 60%」记合格 |
|
||||||
|
| 同一学号去重 | 勾选 | 决定**代表成绩**取最新还是最高分;不影响考试次数与历史完整性 |
|
||||||
|
| 包含暂存答卷 | 不勾选 | 是否统计未正式提交的答卷 |
|
||||||
|
| 每页拉取条数 | `200` | 拉取卷王答卷的分页大小,内部会夹在 20–200 之间 |
|
||||||
|
| 结果缓存(秒) | `15` | 0 = 每次实时拉取。点「立即刷新」会带 `force=1` 跳过缓存 |
|
||||||
|
|
||||||
|
::: details 去重到底影响什么
|
||||||
|
- **代表成绩**:勾选去重后,同一学号只展示一行,取「最新」或「最高」(由去重策略决定);
|
||||||
|
- **考试次数**:始终是**全部提交次数**,与去重开关无关;
|
||||||
|
- **历史**:始终完整,点击任意一行可查看该学生的每一次提交。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 界面
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 默认刷新间隔 | `15` 秒 | 可选 5 / 10 / 15 / 30 / 60 / 120 秒 |
|
||||||
|
| 布局 | `default` | 成绩明细与侧栏面板的排列方式,适配手机 / 平板 / 桌面三端 |
|
||||||
|
| 历史成绩展示 | `click` | `click` = 点击成员弹窗查看;`inline` = 成绩明细内直接展开每次考试 |
|
||||||
|
| 成绩导出排序 | `time` | `time` 提交时间 / `sid` 学号 / `score_desc` 成绩降序 / `score_asc` 成绩升序 |
|
||||||
|
|
||||||
|
## 保存与诊断
|
||||||
|
|
||||||
|
- 保存后会返回明确结果;若连接信息变化,会附带 `ping` 结果告诉你是否真的连上了卷王;
|
||||||
|
- 接口 `action=diag` 与 `action=ping` 可用于排障(需已登录);
|
||||||
|
- 若页面提示 `storage=file`,说明 `pdo_sqlite` 未启用,当前使用 JSON 文件存储,功能不受影响但并发写安全性略低。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [试卷设置(每卷独立)](./project-config.md)
|
||||||
|
- [数据存储(SQLite)](../dev/database.md)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: NY 问卷考试系统数据平台
|
||||||
|
text: 考试成绩实时统计与名单管理
|
||||||
|
tagline: 基于 PHP 8 + 卷王(SurveyKing)社区版开放 API · 零 Composer 依赖 · 开箱即用
|
||||||
|
image:
|
||||||
|
src: /images/home.png
|
||||||
|
alt: NY 问卷考试系统数据平台
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: 快速上手
|
||||||
|
link: /guide/quickstart
|
||||||
|
- theme: alt
|
||||||
|
text: 环境要求与部署
|
||||||
|
link: /guide/install
|
||||||
|
- theme: alt
|
||||||
|
text: 安全加固清单
|
||||||
|
link: /security/hardening
|
||||||
|
|
||||||
|
features:
|
||||||
|
- icon: 🔗
|
||||||
|
title: 直连卷王
|
||||||
|
details: 自动完成 RSA 加密登录、JWT / sk-token 令牌缓存,选择试卷即拉取全部答卷。
|
||||||
|
- icon: 🧩
|
||||||
|
title: 姓名自动关联学号
|
||||||
|
details: 用答卷中的「姓名」题匹配名单,未匹配答卷单独成面板,支持一键补入名单。
|
||||||
|
- icon: 📊
|
||||||
|
title: 多维度统计
|
||||||
|
details: 总人数 / 已提交 / 缺考 / 提交率 / 平均分 / 合格率 / 最高最低分 / 平均用时,按班级即时联动。
|
||||||
|
- icon: ⚙️
|
||||||
|
title: 每卷独立配置
|
||||||
|
details: 满分、合格率、免考名单按试卷隔离设置,互不干扰;未设置时回落到全局默认值。
|
||||||
|
- icon: 🎲
|
||||||
|
title: 随机抽取
|
||||||
|
details: 按班级 / 组别均衡或加权抽取,累计去重、排除管理、历史批次与白名单导出。
|
||||||
|
- icon: 📤
|
||||||
|
title: 导出与打印
|
||||||
|
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
|
||||||
|
- icon: 🔒
|
||||||
|
title: 安全内建
|
||||||
|
details: 会话登录、CSRF 令牌,上传文件白名单 + 随机名 + 解析后即删。
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三分钟了解它
|
||||||
|
|
||||||
|
平台由三个页面组成,顶部导航栏可自由切换:
|
||||||
|
|
||||||
|
| 页面 | 入口 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主系统 | `index.php` | 成绩明细、统计卡片、名单管理、试卷设置、导出与打印 |
|
||||||
|
| 数据大屏 | `dashboard.php` | 首页列出班级入口,点进班级专属大屏(可全屏、自动刷新),适合投屏 |
|
||||||
|
| 随机抽取 | `draw.php` | 从名单或某试卷已提交人员中随机抽取,支持白名单导出 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::: tip 还没部署?
|
||||||
|
先看 [环境要求与部署](/guide/install),再按 [快速上手](/guide/quickstart) 完成连接配置与名单导入,全程不需要写一行代码。
|
||||||
|
:::
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "ny-exam-platform-docs",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "NY 问卷考试系统数据平台 · 使用文档",
|
||||||
|
"scripts": {
|
||||||
|
"docs:dev": "vitepress dev .",
|
||||||
|
"docs:build": "vitepress build .",
|
||||||
|
"docs:preview": "vitepress preview ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitepress": "^1.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||||
|
<title>数据大屏首页(预览)</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="../../assets/style.css">
|
||||||
|
<style>
|
||||||
|
/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */
|
||||||
|
.brand-text{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}
|
||||||
|
.dot.on{background:var(--ok)}
|
||||||
|
.dot.stale{background:var(--danger)}
|
||||||
|
.spacer{flex:1}
|
||||||
|
.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);
|
||||||
|
font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}
|
||||||
|
</style></head>
|
||||||
|
<body class="dash dash-home">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="dash-hero">
|
||||||
|
<div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>
|
||||||
|
<div class="dash-controls">
|
||||||
|
<select id="projectSel"><option value="p1">高一数学月考(31 份)</option></select>
|
||||||
|
<span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>
|
||||||
|
<label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>
|
||||||
|
<select id="intervalSel"><option value="15" selected>15 秒</option></select>
|
||||||
|
<button class="btn" id="btnRefresh">刷新</button>
|
||||||
|
<button class="btn" id="btnFullscreen">⛶ 全屏</button>
|
||||||
|
<a class="btn back" id="linkIndex" href="index.php">← 主系统</a>
|
||||||
|
<a class="btn back" id="linkLogout" href="logout.php">退出</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span id="heroSub">共 4 个班级 · 点击卡片进入该班大屏</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
最后更新:<span id="lastUpdate">2026-09-27 20:57:25</span><i id="liveDot" class="dot on"></i>
|
||||||
|
</div>
|
||||||
|
<div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,
|
||||||
|
数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&class=…</code>,
|
||||||
|
进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>
|
||||||
|
<div id="board">
|
||||||
|
<section class="cards" id="boardSummary">
|
||||||
|
<div class="card accent-blue"><span>总人数</span><b>0</b></div>
|
||||||
|
<div class="card ok accent-green"><span>已提交</span><b>0</b></div>
|
||||||
|
<div class="card warn accent-orange"><span>缺考</span><b>0</b></div>
|
||||||
|
<div class="card accent-teal"><span>平均分</span><b>-</b></div>
|
||||||
|
<div class="card accent-indigo"><span>合格率</span><b>-</b></div>
|
||||||
|
</section>
|
||||||
|
<div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>
|
||||||
|
<div id="boardBody"><div class="cls-grid"><div class="cls-card" data-cls="高一(1)班" role="button" tabindex="0" title="点击进入「高一(1)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(1)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>2</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>2</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>81</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(2)班" role="button" tabindex="0" title="点击进入「高一(2)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(2)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>93</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(3)班" role="button" tabindex="0" title="点击进入「高一(3)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(3)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>90</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div><div class="cls-card" data-cls="高一(4)班" role="button" tabindex="0" title="点击进入「高一(4)班」的独立大屏"><div class="cls-head"><span class="cls-name">高一(4)班</span><span class="cls-go">进入 ›</span></div><div class="cls-kpis"><div class="cls-kpi "><span>总人数</span><b>1</b></div><div class="cls-kpi k-ok"><span>已提交</span><b>1</b></div><div class="cls-kpi "><span>缺考</span><b>0</b></div><div class="cls-kpi "><span>平均分</span><b>76</b></div><div class="cls-kpi k-ok"><span>合格率</span><b>100%</b></div></div><div class="cls-bar"><i style="width:100%;background:var(--ok)"></i></div><div class="cls-sub"><span>提交率 100%</span></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {
|
||||||
|
var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");
|
||||||
|
});
|
||||||
|
});</script>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||||
|
<title>多考试导出 · 每次考试(两行表头)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
|
||||||
|
<div class="page"><h2>多考试导出 · 每次考试(两行表头)</h2><p class="sub">合格线 110 分,达 1 次标绿</p>
|
||||||
|
<table class="bt"><thead><tr><td rowspan="2">学号</td><td rowspan="2">姓名</td><td rowspan="2">第几次</td><td>第一次月考·数学</td><td>第一次月考·英语</td></tr><tr><td>成绩</td><td>成绩</td></tr></thead><tbody><tr><td rowspan="5">356001</td><td rowspan="5">张三</td><td>1</td><td>112</td><td class="max">112</td></tr><tr><td>2</td><td>100</td><td>99</td></tr><tr><td>3</td><td class="max">136</td><td></td></tr><tr><td>4</td><td>127</td><td></td></tr><tr><td>合格次数</td><td class="pass">3</td><td class="pass">1</td></tr><tr><td rowspan="4">356002</td><td rowspan="4">李四</td><td>1</td><td rowspan="3" class="absent">未考</td><td>130</td></tr><tr><td>2</td><td>129</td></tr><tr><td>3</td><td class="max">133</td></tr><tr><td>合格次数</td><td></td><td class="pass">3</td></tr><tr><td rowspan="2">356003</td><td rowspan="2">王五</td><td>1</td><td class="max">95</td><td class="absent">未考</td></tr><tr><td>合格次数</td><td>0</td><td></td></tr></tbody></table>
|
||||||
|
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
|
||||||
|
<p class="sign">教师签字:______________ 教研组长签字:______________ 日期:____________</p>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||||
|
<title>单场导出 · 每次考试(每人一行的宽表)(预览)</title><style>body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}.page h2{font-size:16px;margin:0 0 4px}.page .sub{font-size:12px;color:#777;margin:0 0 14px}table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}table.bt thead tr:first-child td{background:#eaeaea}table.bt td.absent{color:#888}table.bt td.max{color:#d0021b;font-weight:700}table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}.legend{margin:10px 0 0;font-size:12px;color:#555}.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}.legend .r{color:#d0021b;font-weight:700}.legend .g{background:#63c384;font-weight:700}.sign{margin-top:16px;font-size:12px}</style></head><body>
|
||||||
|
<div class="page"><h2>单场导出 · 每次考试(每人一行的宽表)</h2><p class="sub">第一次月考·数学 · 合格线 110 分,达 1 次标绿</p>
|
||||||
|
<table class="bt"><thead><tr><td>学号</td><td>姓名</td><td>第1次</td><td>第2次</td><td>第3次</td><td>第4次</td><td>合格次数</td></tr></thead><tbody><tr><td>356001</td><td>张三</td><td>112</td><td>100</td><td class="max">136</td><td>127</td><td class="pass">3</td></tr><tr><td>356003</td><td>王五</td><td class="max">95</td><td></td><td></td><td></td><td>0</td></tr></tbody></table>
|
||||||
|
<p class="legend">标记说明:<span class="r">最高分</span> 标红、<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),达 1 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。
|
||||||
|
<p class="sign">教师签字:______________ 教研组长签字:______________ 日期:____________</p>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"block": {
|
||||||
|
"title": "多考试导出 · 每次考试(两行表头)",
|
||||||
|
"sheet": {
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"学号",
|
||||||
|
"姓名",
|
||||||
|
"第几次",
|
||||||
|
"第一次月考·数学",
|
||||||
|
"第一次月考·英语"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"成绩",
|
||||||
|
"成绩"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356001",
|
||||||
|
"张三",
|
||||||
|
"1",
|
||||||
|
"112",
|
||||||
|
"112"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"2",
|
||||||
|
"100",
|
||||||
|
"99"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"3",
|
||||||
|
"136",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"4",
|
||||||
|
"127",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"3",
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356002",
|
||||||
|
"李四",
|
||||||
|
"1",
|
||||||
|
"未考",
|
||||||
|
"130"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"2",
|
||||||
|
"",
|
||||||
|
"129"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"3",
|
||||||
|
"",
|
||||||
|
"133"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"",
|
||||||
|
"3"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356003",
|
||||||
|
"王五",
|
||||||
|
"1",
|
||||||
|
"95",
|
||||||
|
"未考"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"合格次数",
|
||||||
|
"0",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"merges": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
7,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
7,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
10,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
11,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
11,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
12,
|
||||||
|
1,
|
||||||
|
13,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
13,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"header_rows": 2,
|
||||||
|
"cell_styles": {
|
||||||
|
"3,5": "red",
|
||||||
|
"5,4": "red",
|
||||||
|
"7,4": "green",
|
||||||
|
"7,5": "green",
|
||||||
|
"10,5": "red",
|
||||||
|
"11,5": "green",
|
||||||
|
"12,4": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"pass_line": 110,
|
||||||
|
"pass_times": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wide": {
|
||||||
|
"title": "单场导出 · 每次考试(每人一行的宽表)",
|
||||||
|
"sheet": {
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"学号",
|
||||||
|
"姓名",
|
||||||
|
"第1次",
|
||||||
|
"第2次",
|
||||||
|
"第3次",
|
||||||
|
"第4次",
|
||||||
|
"合格次数"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356001",
|
||||||
|
"张三",
|
||||||
|
"112",
|
||||||
|
"100",
|
||||||
|
"136",
|
||||||
|
"127",
|
||||||
|
"3"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"356003",
|
||||||
|
"王五",
|
||||||
|
"95",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"merges": [],
|
||||||
|
"header_rows": 1,
|
||||||
|
"cell_styles": {
|
||||||
|
"2,5": "red",
|
||||||
|
"2,7": "green",
|
||||||
|
"3,3": "red"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"pass_line": 110,
|
||||||
|
"pass_times": 1,
|
||||||
|
"exam_name": "第一次月考·数学"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 部署加固清单
|
||||||
|
|
||||||
|
按 **P0 → P2** 顺序执行。P0 不做等于把配置和名单暴露在公网上。
|
||||||
|
|
||||||
|
## P0:屏蔽数据目录(必做)
|
||||||
|
|
||||||
|
`data/` 位于 Web 根目录内,里面有 `ny.db`(配置 / 名单 / 卷王账号密码)、
|
||||||
|
`config.json`、`roster.json`、令牌缓存。**必须禁止直接访问。**
|
||||||
|
|
||||||
|
### nginx(生产环境默认,必做)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /data/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
若站点部署在子路径下(例如 `/index/`),改为对应的前缀:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /index/data/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
验证(应返回 404 或 403,而不是文件内容):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://你的域名/data/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apache
|
||||||
|
|
||||||
|
仓库已自动生成 `.htaccess`。仍需确认:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<Directory "/path/to/ny-exam-platform/data">
|
||||||
|
Require all denied
|
||||||
|
</Directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
且虚拟主机的 `AllowOverride` 不为 `None`。
|
||||||
|
|
||||||
|
### PHP 内置服务器(开发环境)
|
||||||
|
|
||||||
|
**内置服务器既不解析 `.htaccess`,也没有 location 规则**,开发时不要把 8080 端口暴露到公网。
|
||||||
|
|
||||||
|
## P0:排查是否已被上传过 Webshell
|
||||||
|
|
||||||
|
如果你运行过修复前的版本,请立即检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la data/uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
- 正常情况:目录为空(新版本解析后立即删除);
|
||||||
|
- 若存在 `.php` / `.phtml` / `.phar` / `.htaccess` 之外的可疑文件,立即删除;
|
||||||
|
- 检查 nginx access 日志中是否有对 `/data/uploads/*.php` 的 `POST` 请求;
|
||||||
|
- 如确认被利用,请**轮换卷王后台密码**并重新部署干净的目录。
|
||||||
|
|
||||||
|
## P1:全站 HTTPS
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name sj.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
登录密码经 RSA 加密后再提交,但令牌 Cookie 在 HTTP 下可被中间人截获。
|
||||||
|
|
||||||
|
## P1:限制上传体积
|
||||||
|
|
||||||
|
与后端 20 MB 校验保持一致:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 20m;
|
||||||
|
```
|
||||||
|
|
||||||
|
PHP 侧:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 24M
|
||||||
|
```
|
||||||
|
|
||||||
|
## P1:关闭信息泄漏
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server_tokens off;
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
expose_php = Off
|
||||||
|
display_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
```
|
||||||
|
|
||||||
|
## P1:PHP-FPM 最小权限
|
||||||
|
|
||||||
|
- PHP-FPM 进程用户(如 `www`)只对 `data/` 有写权限,其它目录只读;
|
||||||
|
- 禁止在 Web 根目录放 `.git`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ~ /\.git {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## P2:来源 IP 限制(内网场景)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
deny all;
|
||||||
|
```
|
||||||
|
|
||||||
|
## P2:定期备份
|
||||||
|
|
||||||
|
备份 `data/` 目录即可,包含:
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `data/ny.db` | 配置、名单、每卷配置(SQLite) |
|
||||||
|
| `data/config.json` | 配置(文件备份) |
|
||||||
|
| `data/roster.json` | 名单(文件备份) |
|
||||||
|
|
||||||
|
## P2:改掉卷王默认口令
|
||||||
|
|
||||||
|
卷王后台默认 `admin / 123456` 请务必修改,并在平台 **设置** 中同步更新。
|
||||||
|
|
||||||
|
## 加固自检清单
|
||||||
|
|
||||||
|
- [ ] `curl -I https://域名/data/config.json` 返回 404 / 403
|
||||||
|
- [ ] `curl -I https://域名/data/ny.db` 返回 404 / 403
|
||||||
|
- [ ] 上传一个 `.php` 后缀文件被拒绝
|
||||||
|
- [ ] 正常导入 `.xlsx` 后 `data/uploads/` 为空
|
||||||
|
- [ ] 全站 HTTPS,HTTP 自动跳转
|
||||||
|
- [ ] `server_tokens off`、`expose_php = Off`
|
||||||
|
- [ ] `data/` 可写,其它目录只读
|
||||||
|
- [ ] 卷王后台已改默认密码
|
||||||
|
- [ ] `data/` 已纳入定期备份
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [安全总览](./index.md)
|
||||||
|
- [环境要求与部署](../guide/install.md)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 安全总览
|
||||||
|
|
||||||
|
平台内建了多层防护,但**部署方式决定了大部分安全性**——请务必同时阅读
|
||||||
|
[部署加固清单](./hardening.md)。
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
- 采用 PHP Session 鉴权,未登录访问任意页面会被重定向到 `login.php`;
|
||||||
|
- 会话 Cookie 为 `NYSESS`,`HttpOnly` + `SameSite=Lax`;
|
||||||
|
- **登录凭据就是卷王后台账密**:平台用它向卷王换取令牌,不维护独立账号体系;
|
||||||
|
- 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录;
|
||||||
|
若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。
|
||||||
|
|
||||||
|
## 登录失败处理
|
||||||
|
|
||||||
|
登录失败(卷王账号/密码错、地址不可达等)只会在登录页即时提示原因,**不做失败次数限流**——
|
||||||
|
单机内网工具场景下,限流常因配置写错或测试串味把正常用户挡在门外,且无实际防爆破价值。
|
||||||
|
若怀疑连接配置被测试污染导致连不上真实卷王,用 `php tools/fix-config.php` 一键修正连接地址。
|
||||||
|
|
||||||
|
## CSRF 防护
|
||||||
|
|
||||||
|
- 会话绑定 CSRF 令牌:`ny_csrf_token()` 生成,`ny_csrf_ok()` 校验;
|
||||||
|
- 令牌通过 `config_get` 接口下发给前端,前端在写请求的 JSON 体 `_csrf` 字段与
|
||||||
|
`X-CSRF-Token` 头中自动携带;
|
||||||
|
- 后端对写操作维护**白名单** `$WRITE_ACTIONS`,命中白名单的请求一律校验:
|
||||||
|
`config_save` / `roster_save` / `roster_import` / `roster_clear` /
|
||||||
|
`unmatched_map` / `project_config_save`。
|
||||||
|
|
||||||
|
## 文件上传
|
||||||
|
|
||||||
|
::: danger 历史风险(已修复)
|
||||||
|
早期版本中上传文件按 `roster_YYYYMMDD_HHMMSS.<原始扩展名>` 保存在 `data/uploads/`,
|
||||||
|
存在两个问题:
|
||||||
|
|
||||||
|
1. **扩展名未校验**——上传 `.php` 会被存成 `roster_20260910_180400.php`;
|
||||||
|
2. **文件名可枚举**——按时间 1 秒粒度即可遍历。
|
||||||
|
|
||||||
|
在 nginx + PHP-FPM 下(`.htaccess` 无效),这构成**任意文件上传 → 远程代码执行(RCE)**。
|
||||||
|
:::
|
||||||
|
|
||||||
|
当前实现(四层防护):
|
||||||
|
|
||||||
|
| 层 | 措施 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | **扩展名白名单**:仅允许 `.xlsx` / `.xlsm` / `.csv` / `.txt`,其余直接拒绝 |
|
||||||
|
| 2 | **随机文件名**:`bin2hex(random_bytes(16))`,无法枚举 |
|
||||||
|
| 3 | **解析后立即删除**:`@unlink($dest)`,Web 目录下不留存任何上传文件 |
|
||||||
|
| 4 | **大小限制**:单个文件 ≤ 20 MB;同时建议 nginx 配置 `client_max_body_size 20m` |
|
||||||
|
|
||||||
|
另外,上传接口属于写操作,同样受 CSRF 保护。
|
||||||
|
|
||||||
|
## XSS / 注入
|
||||||
|
|
||||||
|
- 所有输出到 HTML 的值均经过 `htmlspecialchars()`;
|
||||||
|
- 导出 XLSX 时单元格统一写入**共享字符串**并施加 `numFmtId=49` 文本样式,
|
||||||
|
天然防止 CSV / Excel 公式注入,也避免长学号被转成科学计数法;
|
||||||
|
- 数据访问全部使用 PDO 预处理语句 / 参数绑定。
|
||||||
|
|
||||||
|
## 目录保护
|
||||||
|
|
||||||
|
程序启动时会在 `data/`、`data/cache/`、`data/uploads/`
|
||||||
|
自动生成 `.htaccess`(`Deny from all`)与 `index.html` 占位。
|
||||||
|
|
||||||
|
> **nginx 不读取 `.htaccess`**,必须显式配置。见 [部署加固清单](./hardening.md)。
|
||||||
|
|
||||||
|
## 建议的额外措施
|
||||||
|
|
||||||
|
| 措施 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 全站 HTTPS | 登录密码经 RSA 加密,但仍建议全站 TLS |
|
||||||
|
| IP 白名单 | 内网使用时在 nginx 层限制来源 IP |
|
||||||
|
| 隐藏版本号 | 关闭 `expose_php`、隐藏 nginx 版本号 |
|
||||||
|
| 定期备份 | 备份 `data/`(配置、名单、每卷配置、SQLite 库) |
|
||||||
|
| 最小权限 | PHP-FPM 进程只对 `data/` 可写,其它目录只读 |
|
||||||
|
| 改默认密码 | 卷王后台默认 `admin/123456` 务必修改 |
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [部署加固清单](./hardening.md)
|
||||||
|
- [环境要求与部署](../guide/install.md)
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* NY 问卷考试系统数据平台 - 随机抽取
|
* NY 问卷考试系统数据平台 - 随机抽取
|
||||||
* 从名单 / 已提交人员中随机抽取(支持分组均衡 5:5、排除名单、历史记录),
|
* 从名单 / 已提交人员中随机抽取(支持按人数加权/均分/自定义比例/不分组、排除名单、历史记录),
|
||||||
* 导出为卷王白名单(两列:姓名 / 登陆账号)
|
* 导出为卷王白名单(两列:姓名 / 登陆账号)
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
if (!ny_is_installed()) {
|
|
||||||
header('Location: install.php');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if (!ny_is_auth()) {
|
if (!ny_is_auth()) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
@@ -30,11 +26,7 @@ $rosterCount = Roster::count();
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
|
||||||
<strong>随机抽取</strong>
|
|
||||||
<small>名单 / 已提交人员 → 卷王白名单</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
<a class="btn" href="index.php#roster" title="前往主系统管理学号名单">名单管理 <b id="rosterBadge"><?= (int)$rosterCount ?></b></a>
|
||||||
@@ -77,16 +69,22 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field btns">
|
<div class="field btns">
|
||||||
<label class="chk" title="有多个组别时按组别均衡抽取(如 A:B = 5:5),无组别时按班级均衡"><input type="checkbox" id="balanceChk" checked> 分组均衡</label>
|
<label class="chk" title="决定有分组时如何分配名额"><span style="color:var(--text-2);font-size:12px">分组比例</span>
|
||||||
|
<select id="balanceMode" style="height:30px;border-radius:8px;border:1px solid var(--border);padding:0 8px;background:#fff;margin-left:4px">
|
||||||
|
<option value="weight">按人数加权(默认)</option>
|
||||||
|
<option value="even">各组均分(5:5)</option>
|
||||||
|
<option value="custom">自定义比例</option>
|
||||||
|
<option value="off">不分组(整池随机)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
|
<label class="chk" title="再次抽取时不与本次会话已抽中的人重复;「重置结果」后清零"><input type="checkbox" id="excludePicked" checked> 排除已抽中</label>
|
||||||
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
|
<button class="btn" id="btnExclude" title="勾选不参与抽取的人">🚫 排除管理</button>
|
||||||
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
|
<button class="btn btn-primary" id="btnDraw">🎲 开始抽取</button>
|
||||||
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
|
<button class="btn" id="btnReset" title="清空本次结果与累计已抽取(历史记录保留)">重置结果</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field full hidden" id="ratioBox" style="grid-column:1/-1"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="alert" class="alert hidden"></div>
|
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
<div class="card accent-blue"><span>候选人数</span><b id="dPool">-</b></div>
|
||||||
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
<div class="card accent-green"><span>本次抽取</span><b id="dPicked">-</b></div>
|
||||||
@@ -100,6 +98,7 @@ $rosterCount = Roster::count();
|
|||||||
<h2>抽取结果 <small id="drawHint"></small></h2>
|
<h2>抽取结果 <small id="drawHint"></small></h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
|
<button class="btn" id="btnLog" title="查看历史抽取批次(重置结果后仍保留)">📜 历史 <b id="logBadge">0</b></button>
|
||||||
|
<button class="btn" id="btnCopy" title="复制姓名 / 登陆账号到剪贴板(可直接粘贴到微信 / Excel)">📋 复制名单</button>
|
||||||
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
|
<button class="btn" id="btnDlImport" title="导出为「白名单导入模板.xlsx」">⬇ 白名单导入模板</button>
|
||||||
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
|
<button class="btn btn-primary" id="btnDlList" title="导出为「白名单列表.xlsx」">⬇ 白名单列表</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,18 +110,18 @@ $rosterCount = Roster::count();
|
|||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:70px">序号</th>
|
<th style="width:8%">序号</th>
|
||||||
<th style="width:26%">学号</th>
|
<th style="width:22%">学号</th>
|
||||||
<th style="width:22%">姓名</th>
|
<th style="width:18%">姓名</th>
|
||||||
<th>班级</th>
|
<th style="width:24%">班级</th>
|
||||||
<th>组别</th>
|
<th style="width:18%">组别</th>
|
||||||
<th style="width:90px">操作</th>
|
<th style="width:10%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
<tbody id="drawBody"><tr><td colspan="6" class="empty">尚未抽取</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">两个下载均为两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列(缺失时回退学号),按文本格式保存,可直接导入卷王白名单。</p>
|
<p class="hint">抽取结果表不显示登陆账号;「⬇ 白名单导入模板」「⬇ 白名单列表」与「📋 复制名单」均导出两列「<b>姓名</b> / <b>登陆账号</b>」,登陆账号取名单中导入的「登陆账号」列。<b>若名单未填登陆账号,导出时会自动回退用学号</b>,请先到主系统「名单管理」补全,否则学生无法登录考试。</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th style="width:64px">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:10%;text-align:center">排除</th><th style="width:24%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:18%">组别</th></tr></thead>
|
||||||
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
<tbody id="exclBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
if (!ny_is_installed()) {
|
|
||||||
header('Location: install.php');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if (!ny_is_auth()) {
|
if (!ny_is_auth()) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
@@ -26,11 +22,7 @@ $rosterCount = Roster::count();
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">NY</span>
|
<h1 class="brand-title">NY问卷考试系统数据平台</h1>
|
||||||
<div class="brand-text">
|
|
||||||
<strong>NY问卷考试系统数据平台</strong>
|
|
||||||
<small>卷王问卷系统 · 实时数据统计</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
<div id="conn" class="conn conn-off"><i></i><span>未连接</span></div>
|
||||||
@@ -78,27 +70,30 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="field btns">
|
<div class="field btns">
|
||||||
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
<button class="btn btn-primary" id="btnRefresh">立即刷新</button>
|
||||||
|
<button class="btn btn-primary" id="btnProjCfg" title="为当前选中的试卷单独设置满分 / 合格率 / 免考名单">试卷设置</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field grow-2">
|
<div class="field grow-2">
|
||||||
<label>搜索</label>
|
<label>搜索</label>
|
||||||
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
<input type="text" id="kw" placeholder="输入学号或姓名筛选">
|
||||||
</div>
|
</div>
|
||||||
<div class="field btns">
|
|
||||||
<button class="btn" id="btnExport">导出成绩</button>
|
|
||||||
<button class="btn" id="btnExportAbsent">导出缺考</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="alert" class="alert hidden"></div>
|
<section class="subbar">
|
||||||
|
<span class="subbar-label">导出</span>
|
||||||
|
<span class="hint" id="exportBarHint">成绩明细 / 每次考试 / 缺考名单 / 多考试对比,都在「导出」里配置</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-primary" id="btnExportOpen" title="导出与打印:选好导出内容、范围与标记方式,再导出 Excel 或直接打印">导出</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="cards" id="statCards">
|
<section class="cards" id="statCards">
|
||||||
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
<div class="card accent-blue"><span>名单总人数</span><b id="sTotal">-</b></div>
|
||||||
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
<div class="card ok accent-green"><span>已提交</span><b id="sSubmit">-</b></div>
|
||||||
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="sAbsent">-</b></div>
|
||||||
|
<div class="card accent-purple"><span>提交率</span><b id="sRate">-</b></div>
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="sAvg">-</b></div>
|
||||||
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
<div class="card accent-green"><span>最高分</span><b id="sMax">-</b></div>
|
||||||
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
<div class="card accent-red"><span>最低分</span><b id="sMin">-</b></div>
|
||||||
<div class="card accent-indigo"><span>合格率(≥60%满分)</span><b id="sPass">-</b></div>
|
<div class="card accent-indigo"><span>合格率<span id="sPassLabel">(≥60%满分)</span></span><b id="sPass">-</b></div>
|
||||||
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
<div class="card accent-gray"><span>平均用时</span><b id="sDur">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -106,42 +101,53 @@ $rosterCount = Roster::count();
|
|||||||
<section class="panel" id="tablePanel">
|
<section class="panel" id="tablePanel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>成绩明细 <small id="tableHint"></small></h2>
|
<h2>成绩明细 <small id="tableHint"></small></h2>
|
||||||
<div class="updated">最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i></div>
|
<div class="updated">
|
||||||
|
<button class="btn sm" id="btnClearHl" hidden title="清除新数据的绿色高亮标记(每 30 秒自动清除一次)">清除高亮 <b id="hlBadge">0</b></button>
|
||||||
|
<button class="btn sm" id="btnPrintTable" title="打印成绩明细(默认按成绩降序,可选分组对比)">打印</button>
|
||||||
|
最后更新:<span id="lastUpdate">—</span><i id="liveDot" class="dot"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table class="grid" id="dataTable">
|
<table class="grid" id="dataTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" data-k="sid">学号</th>
|
<th class="sortable" data-k="sid" style="width:16%">学号</th>
|
||||||
<th class="sortable" data-k="name">姓名</th>
|
<th class="sortable" data-k="name" style="width:12%">姓名</th>
|
||||||
<th class="sortable" data-k="cls">班级</th>
|
<th class="sortable" data-k="cls" style="width:10%">班级</th>
|
||||||
<th class="sortable" data-k="grp">组别</th>
|
<th class="sortable" data-k="grp" style="width:7%">组别</th>
|
||||||
<th class="sortable" data-k="score">成绩</th>
|
<th class="sortable" data-k="score" style="width:10%">成绩</th>
|
||||||
<th class="sortable" data-k="submit_ts">提交时间</th>
|
<th class="sortable" data-k="attempts" style="width:11%" title="该生提交过的次数,点击表头可排序">考试次数</th>
|
||||||
<th class="sortable" data-k="duration">考试时长</th>
|
<th class="sortable" data-k="submit_ts" style="width:22%" title="最近一次提交时间">提交时间</th>
|
||||||
|
<th class="sortable" data-k="duration" style="width:12%">考试时长</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tbody"><tr><td colspan="7" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
<tbody id="tbody"><tr><td colspan="8" class="empty">请选择问卷 / 考试</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="absentPanel">
|
<section class="panel" id="absentPanel">
|
||||||
<div class="panel-head"><h2>缺考名单 <small id="absentHint"></small></h2></div>
|
<div class="panel-head">
|
||||||
|
<h2>缺考名单 <small id="absentHint"></small></h2>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn sm" id="btnCopySids" title="复制全部缺考学号(逗号分隔)">复制学号</button>
|
||||||
|
<button class="btn sm" id="btnCopyNames" title="复制全部缺考姓名">复制姓名</button>
|
||||||
|
<button class="btn sm" id="btnPrintAbsent" title="打印缺考名单">打印</button>
|
||||||
|
</div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact" id="absentTable">
|
||||||
<thead><tr><th>学号</th><th>姓名</th><th>班级</th><th>组别</th></tr></thead>
|
<thead><tr><th style="width:25%">学号</th><th style="width:30%">姓名</th><th style="width:25%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
<tbody id="absentBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="unmatchedPanel">
|
<section class="panel" id="unmatchedPanel">
|
||||||
<div class="panel-head"><h2>未匹配答卷 <small>姓名不在名单中</small></h2></div>
|
<div class="panel-head"><h2>未匹配答卷 <small id="unmatchedHint">姓名不在名单中</small></h2></div>
|
||||||
<div class="table-scroll short">
|
<div class="table-scroll short">
|
||||||
<table class="grid compact">
|
<table class="grid compact">
|
||||||
<thead><tr><th>姓名</th><th>成绩</th><th>提交时间</th></tr></thead>
|
<thead><tr><th style="width:25%">姓名</th><th style="width:18%" data-k="score">成绩</th><th style="width:35%" data-k="submit_ts">提交时间</th><th style="width:22%">操作</th></tr></thead>
|
||||||
<tbody id="unmatchedBody"><tr><td colspan="3" class="empty">—</td></tr></tbody>
|
<tbody id="unmatchedBody"><tr><td colspan="4" class="empty">—</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -172,35 +178,84 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>每页拉取条数</label>
|
<label>每页拉取条数</label>
|
||||||
<input type="number" id="cfgPageSize" min="20" max="1000">
|
<input type="number" id="cfgPageSize" min="20" max="200">
|
||||||
|
<p class="hint">实际生效上限 200,超出会自动夹到 200(防止单页内存耗尽)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
<div class="fi">
|
||||||
<label>默认满分(用于合格率)</label>
|
<label>全局默认满分</label>
|
||||||
<input type="number" id="cfgFull" min="1" max="1000">
|
<input type="number" id="cfgFull" min="1" max="1000">
|
||||||
<p class="hint" id="cfgFullHint">未选中试卷时的默认值</p>
|
<p class="hint" id="cfgFullHint">全局默认;每份试卷可在主页面「试卷设置」中单独覆盖</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>全局默认合格率(%)</label>
|
||||||
|
<input type="number" id="cfgPassRate" min="1" max="100" step="1">
|
||||||
|
<p class="hint">达到满分的该百分比即算合格;每份试卷可单独覆盖</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>历史成绩展示方式</label>
|
||||||
|
<select id="cfgHistoryDisplay">
|
||||||
|
<option value="click">点击成员弹窗查看(默认)</option>
|
||||||
|
<option value="inline">成绩明细内全部展开</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">「全部展开」时每次考试单独一行,直接显示在成绩明细中</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>成绩导出排序</label>
|
||||||
|
<select id="cfgExportSort">
|
||||||
|
<option value="time">按提交时间(默认)</option>
|
||||||
|
<option value="sid">按学号(同学号按提交时间)</option>
|
||||||
|
<option value="score_desc">按成绩降序</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">导出包含每次考试记录,始终带「考试次数」列</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi full">
|
<div class="fi full">
|
||||||
<label>姓名题关键词</label>
|
<label>姓名题关键词</label>
|
||||||
<input type="text" id="cfgKeywords">
|
<input type="text" id="cfgKeywords">
|
||||||
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
<p class="hint">英文逗号分隔,用于自动识别"姓名"题目</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fi">
|
|
||||||
<label>各试卷独立满分</label>
|
|
||||||
<div id="fullScoresBox" class="fullscores">
|
|
||||||
<span class="hint" id="fullScoresHint">未选中试卷时,此处为空;选中某套试卷后保存即在设置中为该卷单独设定满分</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="fi full checks">
|
<div class="fi full checks">
|
||||||
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
<label><input type="checkbox" id="cfgDedupe"> 同一学号只保留最新一份答卷</label>
|
||||||
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
<label><input type="checkbox" id="cfgTemp"> 包含"暂存"未提交的答卷</label>
|
||||||
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
<label><input type="checkbox" id="cfgSsl"> 校验 HTTPS 证书</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="hint" style="margin-top:-4px">
|
||||||
|
<b>「只保留最新一份」说明</b>:勾选后主明细表的「成绩」列取该生最新一次提交的分数,
|
||||||
|
取消勾选则取最高分。<b>无论是否勾选,「考试次数」与「历史成绩」始终展示全部提交记录</b>。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="fi full" style="margin-top:6px">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<label style="margin:0">🔌 卷王系统连接</label>
|
||||||
|
<span class="hint" id="cfgConnInfo" style="font-size:12px"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fi full">
|
||||||
|
<label>系统地址</label>
|
||||||
|
<input type="text" id="cfgBase" placeholder="https://exam.nxxy335.top" spellcheck="false">
|
||||||
|
<p class="hint">卷王后台地址,结尾不要带 <code>/</code>;修改后保存会自动重连</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>接口前缀</label>
|
||||||
|
<input type="text" id="cfgPrefix" placeholder="/api" spellcheck="false">
|
||||||
|
<p class="hint">社区版 <code>/api</code>;企业版可能是 <code>/admin-api</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>登录账号</label>
|
||||||
|
<input type="text" id="cfgUser" autocomplete="username" placeholder="登录卷王的账号">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>登录密码</label>
|
||||||
|
<input type="password" id="cfgPass" autocomplete="new-password" placeholder="已保存 · 留空表示不修改">
|
||||||
|
<p class="hint">为安全,已保存的密码不回显;如需修改请直接输入新密码后保存</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pingResult" class="ping-result hidden"></div>
|
<div id="pingResult" class="ping-result hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<span class="hint" style="align-self:center">连接信息由安装向导配置,此处仅调整显示与统计</span>
|
<span class="hint" style="align-self:center">修改地址 / 前缀 / 账号 / 密码后保存会自动重新连接</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" id="btnTestConn">测试连接</button>
|
||||||
<button class="btn" data-close>取消</button>
|
<button class="btn" data-close>取消</button>
|
||||||
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
|
<button class="btn btn-primary" id="btnSaveCfg">保存</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,19 +275,21 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="roster-bar">
|
<div class="roster-bar">
|
||||||
|
<button class="btn sm" id="btnTpl" title="下载名单导入模板(CSV,含「登陆账号」列)">⬇ 下载模板</button>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<label class="chk"><input type="radio" name="impMode" value="replace" checked> 覆盖</label>
|
<label class="chk"><input type="radio" name="impMode" value="replace"> 覆盖</label>
|
||||||
<label class="chk"><input type="radio" name="impMode" value="append"> 追加</label>
|
<label class="chk"><input type="radio" name="impMode" value="append" checked> 追加</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
<input type="text" id="rosterSearch" placeholder="搜索学号 / 姓名">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
<button class="btn sm" id="btnAddRow">+ 新增</button>
|
||||||
|
<button class="btn sm danger" id="btnDelSel" hidden>删除选中</button>
|
||||||
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
<button class="btn sm danger" id="btnClearRoster">清空</button>
|
||||||
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
<button class="btn sm btn-primary" id="btnSaveRoster">保存名单</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll mid">
|
<div class="table-scroll mid">
|
||||||
<table class="grid compact" id="rosterTable">
|
<table class="grid compact" id="rosterTable">
|
||||||
<thead><tr><th style="width:28%">学号</th><th style="width:22%">姓名</th><th>班级</th><th>组别</th><th style="width:70px">操作</th></tr></thead>
|
<thead><tr><th style="width:6%"><input type="checkbox" id="rosterCheckAll" title="全选"></th><th style="width:26%">学号</th><th style="width:22%">姓名</th><th style="width:26%">班级</th><th style="width:20%">组别</th></tr></thead>
|
||||||
<tbody id="rosterBody"></tbody>
|
<tbody id="rosterBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,6 +302,356 @@ $rosterCount = Roster::count();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多次刷题明细弹窗 -->
|
||||||
|
<div class="modal" id="attemptsModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3 id="attTitle">提交明细</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body" id="attBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增学生弹窗 -->
|
||||||
|
<div class="modal" id="addStudentModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>新增学生</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="fi">
|
||||||
|
<label>学号 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="addSid" placeholder="必填">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>姓名 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="addName" placeholder="必填">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>班级</label>
|
||||||
|
<input type="text" id="addCls" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>组别</label>
|
||||||
|
<input type="text" id="addGrp" placeholder="如 A / B,可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi" hidden>
|
||||||
|
<label>登陆账号</label>
|
||||||
|
<input type="text" id="addLogin" placeholder="可选,用于抽取页登录">
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="addStudentHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnAddStudentOk">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未匹配答卷映射到名单 -->
|
||||||
|
<div class="modal" id="mapUnmatchedModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>加入名单</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint" style="margin:0 0 12px">答卷姓名:<b id="mapUmName"></b>(不在名单中)</p>
|
||||||
|
<div class="fi">
|
||||||
|
<label>学号 <b class="req">*</b></label>
|
||||||
|
<input type="text" id="mapUmSid" placeholder="输入该生的学号">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>班级</label>
|
||||||
|
<input type="text" id="mapUmCls" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>组别</label>
|
||||||
|
<input type="text" id="mapUmGrp" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="mapUmHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnMapUmOk">加入名单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成绩明细打印设置 -->
|
||||||
|
<div class="modal" id="printTableModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>打印成绩明细</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="fi">
|
||||||
|
<label>打印标题</label>
|
||||||
|
<input type="text" id="ptTitle" placeholder="默认:试卷名 + 成绩明细">
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>排序方式</label>
|
||||||
|
<select id="ptSort">
|
||||||
|
<option value="score_desc" selected>按成绩降序(默认)</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
|
<option value="sid">按学号</option>
|
||||||
|
<option value="time">按提交时间</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>分组对比</label>
|
||||||
|
<select id="ptGroup">
|
||||||
|
<option value="none" selected>不分组(连续打印)</option>
|
||||||
|
<option value="cls">按班级分组</option>
|
||||||
|
<option value="grp">按组别分组</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">分组后每个班/组单独一段,段首显示小计(人数/平均/最高/最低/合格率)</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>纸张方向</label>
|
||||||
|
<select id="ptOrient">
|
||||||
|
<option value="portrait" selected>纵向(A4,列少时清晰)</option>
|
||||||
|
<option value="landscape">横向(A4,列多时不易折行)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>打印范围</label>
|
||||||
|
<select id="ptScope">
|
||||||
|
<option value="latest" selected>每人一行(汇总)</option>
|
||||||
|
<option value="all">每次考试一行(含历史提交)</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">「每次考试一行」会把每位学生的每次提交各打一行,完整留存历史。</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>每人成绩口径</label>
|
||||||
|
<select id="ptRowScore" title="每人一行时的代表成绩;选「每次考试一行」时本项不生效">
|
||||||
|
<option value="latest" selected>最新成绩(最近一次提交)</option>
|
||||||
|
<option value="best">最高成绩(该生最高分)</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">与「导出 → 成绩明细」的「每人成绩」一致,默认最新成绩。</p>
|
||||||
|
</div>
|
||||||
|
<div class="fi checks">
|
||||||
|
<label><input type="checkbox" id="ptRank" checked> 显示名次(全表连续编号)</label>
|
||||||
|
<label><input type="checkbox" id="ptPass"> 显示「是否合格」列(默认不显示)</label>
|
||||||
|
<label><input type="checkbox" id="ptAttempts" checked> 显示考试次数</label>
|
||||||
|
<label><input type="checkbox" id="ptAbsent" title="成绩表后另起一页附缺考名单,含备注列供手写"> 附缺考名单(另起一页)</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">打印内容<b>不受页面当前筛选外的影响</b>,始终按当前班级/组别筛选结果输出;表头会在每页自动重复,末尾附签字栏。</p>
|
||||||
|
<p class="hint">要打印<b>多场考试对比</b>或<b>「每次考试」表</b>(最高分标红、合格次数达标绿底)?用工具栏的 <b>导出</b> 弹窗,选「多考试对比」或「每次考试」后点「打印」。</p>
|
||||||
|
<p class="hint" id="ptHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnPtPrint">打印</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 试卷独立设置(满分 / 合格率 / 免考名单)——仅对当前选中试卷生效 -->
|
||||||
|
<div class="modal" id="projCfgModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head"><h3>试卷设置 <small id="projCfgSub"></small></h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint" style="margin:0 0 12px">以下设置<b>仅对当前选中的这份试卷生效</b>,各试卷之间互不影响。留空表示跟随全局默认。</p>
|
||||||
|
<div class="fi">
|
||||||
|
<label>本卷满分</label>
|
||||||
|
<input type="number" id="pcFull" min="1" max="1000" placeholder="留空=用全局默认">
|
||||||
|
<p class="hint" id="pcFullHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>本卷合格率(%)</label>
|
||||||
|
<input type="number" id="pcPassRate" min="1" max="100" step="1" placeholder="留空=用全局默认">
|
||||||
|
<p class="hint" id="pcPassHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="fi">
|
||||||
|
<label>免考学生(学号)</label>
|
||||||
|
<textarea id="pcExempt" rows="4" placeholder="每行一个学号,也可用逗号分隔"></textarea>
|
||||||
|
<p class="hint">免考学生不计入「名单总人数」与「缺考名单」</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn danger" id="btnPcReset">清除本卷设置</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn btn-primary" id="btnPcSave">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 统一导出 / 打印 =================
|
||||||
|
所有导出(成绩明细 / 每次考试 / 缺考名单 / 多考试对比)与打印都在这一个弹窗里配置:
|
||||||
|
先选「导出什么」,再按需展开「范围」「列与标记」「打印」三个区块。 -->
|
||||||
|
<div class="modal" id="exportModal">
|
||||||
|
<div class="modal-box wide">
|
||||||
|
<div class="modal-head"><h3>导出 / 打印</h3><button class="x" data-close>×</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<!-- 1. 导出内容 -->
|
||||||
|
<div class="ex-sec">
|
||||||
|
<div class="ex-sec-title">导出内容</div>
|
||||||
|
<div class="ex-types">
|
||||||
|
<label class="ex-type" data-for="detail">
|
||||||
|
<input type="radio" name="exType" value="detail" checked>
|
||||||
|
<span class="ex-type-t">
|
||||||
|
<b>成绩明细</b>
|
||||||
|
<small>每人一行:学号 / 姓名 / 班级 / 组别 / 成绩 / 次数 / 时间 / 时长</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="ex-type" data-for="attempts">
|
||||||
|
<input type="radio" name="exType" value="attempts">
|
||||||
|
<span class="ex-type-t">
|
||||||
|
<b>每次考试</b>
|
||||||
|
<small>按截图版式:一行一次考试,最高分标红、合格次数标绿</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="ex-type" data-for="absent">
|
||||||
|
<input type="radio" name="exType" value="absent">
|
||||||
|
<span class="ex-type-t">
|
||||||
|
<b>缺考名单</b>
|
||||||
|
<small>学号 / 姓名 / 班级 / 组别(跟随下方筛选范围)</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="ex-type" data-for="multi">
|
||||||
|
<input type="radio" name="exType" value="multi">
|
||||||
|
<span class="ex-type-t">
|
||||||
|
<b>多考试对比</b>
|
||||||
|
<small>勾选多场考试,横向对比:每场一列,最高分标红、合格次数标绿</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. 统计范围(班级 / 组别) -->
|
||||||
|
<div class="ex-sec" id="exScopeSec">
|
||||||
|
<div class="ex-sec-title">统计范围 <small id="exScopeSum"></small></div>
|
||||||
|
<div class="ex-row">
|
||||||
|
<label class="ex-field">班级
|
||||||
|
<select id="exClsSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部班级"></select>
|
||||||
|
</label>
|
||||||
|
<label class="ex-field">组别
|
||||||
|
<select id="exGrpSel" multiple size="4" title="按住 Ctrl / ⌘ 可多选;不选 = 全部组别"></select>
|
||||||
|
</label>
|
||||||
|
<div class="ex-field ex-field-tip">
|
||||||
|
<span class="hint" id="exScopeHint">不选 = 全部(Ctrl / ⌘ 多选)</span>
|
||||||
|
<span class="hint">选多个班级 / 组别时,导出会自动补出对应列并按相同值合并单元格。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. 明细列与标记(成绩明细 / 每次考试 用) -->
|
||||||
|
<div class="ex-sec" id="exDetailSec">
|
||||||
|
<div class="ex-sec-title">成绩口径与列</div>
|
||||||
|
<div class="ex-row">
|
||||||
|
<label class="ex-field">每人成绩
|
||||||
|
<select id="exRowScore" title="每人一行时的代表成绩:最新 = 最近一次真实提交;最高 = 该生最高分">
|
||||||
|
<option value="latest" selected>最新成绩</option>
|
||||||
|
<option value="best">最高成绩</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ex-field">排序
|
||||||
|
<select id="exSort">
|
||||||
|
<option value="time">按提交时间</option>
|
||||||
|
<option value="sid">按学号</option>
|
||||||
|
<option value="score_desc">按成绩降序</option>
|
||||||
|
<option value="score_asc">按成绩升序</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ex-field">出表方式
|
||||||
|
<select id="exDetailMode">
|
||||||
|
<option value="latest" selected>每人一行(汇总)</option>
|
||||||
|
<option value="all">每次考试一行(明细)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ex-checks" id="exDetailChecks">
|
||||||
|
<label><input type="checkbox" id="exSplit"> 按班级分工作表</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="exDetailHint">每人一行时可用「每人成绩」切换最新 / 最高;「每次考试一行」会保留每位学生的历史提交。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3.5 附加工作表(成绩明细 / 每次考试 / 多考试对比 共用) -->
|
||||||
|
<div class="ex-sec" id="exExtraSec">
|
||||||
|
<div class="ex-sec-title">附加内容</div>
|
||||||
|
<div class="ex-checks">
|
||||||
|
<label><input type="checkbox" id="exWithAbsent" checked> 附缺考名单(名单里有、所选考试全都没交的人)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 标记与合格次数(成绩明细 / 每次考试 / 多考试对比 共用) -->
|
||||||
|
<div class="ex-sec" id="exMarkSec">
|
||||||
|
<div class="ex-sec-title">标记与合格次数</div>
|
||||||
|
<div class="ex-row">
|
||||||
|
<label class="ex-field">合格次数阈值
|
||||||
|
<input type="number" id="exPassTimes" min="1" max="20" step="1" value="1"
|
||||||
|
title="同一场考试里「达到合格线」的次数 ≥ 此值才标绿(默认 1 次)">
|
||||||
|
</label>
|
||||||
|
<div class="ex-field ex-field-tip">
|
||||||
|
<span class="hint">合格线 = 该场考试满分 × 合格率(在「试卷设置」里按卷配置,缺省用全局)</span>
|
||||||
|
<span class="hint">例:阈值填 2,则该场考到合格线 ≥ 2 次才把「合格次数」标绿。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ex-checks">
|
||||||
|
<label><input type="checkbox" id="exMarkBest" checked> 最高分标红</label>
|
||||||
|
<label><input type="checkbox" id="exPassRow" checked> 输出「合格次数」行(达标绿底)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. 多考试内容 -->
|
||||||
|
<div class="ex-sec" id="exMultiSec" hidden>
|
||||||
|
<div class="ex-sec-title">多考试对比</div>
|
||||||
|
<div class="ex-row">
|
||||||
|
<label class="ex-field">表格形态
|
||||||
|
<select id="exMultiMode">
|
||||||
|
<option value="block" selected>两行表头(每次考试 + 合格次数)</option>
|
||||||
|
<option value="summary">成绩对比(每人一行)</option>
|
||||||
|
<option value="both">两者都出</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ex-field">对比成绩
|
||||||
|
<select id="exMultiScore">
|
||||||
|
<option value="latest" selected>最新成绩</option>
|
||||||
|
<option value="highest">最高成绩</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ex-pick">
|
||||||
|
<div class="ex-pick-head">
|
||||||
|
<span>勾选要对比的考试 <small id="exPickCount"></small></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn sm" id="btnExPickAll" type="button">全选</button>
|
||||||
|
<button class="btn sm" id="btnExPickNone" type="button">清空</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll mid">
|
||||||
|
<table class="grid compact" id="exExamTable">
|
||||||
|
<thead><tr><th style="width:8%"><input type="checkbox" id="exCheckAll" title="全选"></th><th style="width:8%">顺序</th><th>考试名称</th></tr></thead>
|
||||||
|
<tbody id="exExamBody"><tr><td colspan="3" class="empty">(暂无可导出的考试,请先在右上角刷新问卷列表)</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. 打印设置 -->
|
||||||
|
<div class="ex-sec" id="exPrintSec" hidden>
|
||||||
|
<div class="ex-sec-title">打印设置</div>
|
||||||
|
<div class="ex-row">
|
||||||
|
<label class="ex-field">纸张方向
|
||||||
|
<select id="exOrient" title="多考试打印默认横向(列多,横向不易折行)">
|
||||||
|
<option value="landscape" selected>横向(A4)</option>
|
||||||
|
<option value="portrait">纵向(A4)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ex-field ex-field-grow">打印标题
|
||||||
|
<input type="text" id="exPrintTitle" placeholder="留空 = 用考试名自动生成">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint" id="exSummaryLine" style="margin-top:4px"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn" data-close>取消</button>
|
||||||
|
<button class="btn" id="btnExPrint">打印</button>
|
||||||
|
<button class="btn btn-primary" id="btnExExport">导出 Excel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.NY = {
|
window.NY = {
|
||||||
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
appName: <?= json_encode(NY_APP_NAME, JSON_UNESCAPED_UNICODE) ?>,
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 首次安装向导:配置数据存储后端(文件 / PostgreSQL / MariaDB)与卷王系统连接信息。
|
|
||||||
* 访问本页时若已安装则自动跳转 /login。
|
|
||||||
*/
|
|
||||||
declare(strict_types=1);
|
|
||||||
require __DIR__ . '/bootstrap.php';
|
|
||||||
|
|
||||||
// 已安装:默认跳转登录页;仅当带 ?force=1 或提交 POST 时才允许重新配置
|
|
||||||
if (ny_is_installed() && empty($_GET['force']) && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
header('Location: login.php');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$err = '';
|
|
||||||
$ok = false;
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
$backend = (string)($_POST['backend'] ?? 'file');
|
|
||||||
$base = rtrim(trim((string)($_POST['base_url'] ?? '')), '/');
|
|
||||||
$prefix = '/' . trim((string)($_POST['api_prefix'] ?? '/api'), '/');
|
|
||||||
$user = trim((string)($_POST['username'] ?? ''));
|
|
||||||
$pwd = (string)($_POST['password'] ?? '');
|
|
||||||
$verify = !empty($_POST['verify_ssl']);
|
|
||||||
$full = (int)($_POST['full_score'] ?? 100);
|
|
||||||
if ($full < 1) {
|
|
||||||
$full = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($base === '' || $user === '') {
|
|
||||||
$err = '请填写卷王系统地址与登录账号。';
|
|
||||||
} else {
|
|
||||||
// 1) 若是数据库后端,先保存连接参数并测试+建表
|
|
||||||
if ($backend === 'pgsql' || $backend === 'mysql') {
|
|
||||||
$dbp = [
|
|
||||||
'driver' => $backend,
|
|
||||||
'host' => trim((string)($_POST['db_host'] ?? '127.0.0.1')),
|
|
||||||
'port' => (int)($_POST['db_port'] ?? ($backend === 'pgsql' ? 5432 : 3306)),
|
|
||||||
'dbname' => trim((string)($_POST['db_name'] ?? '')),
|
|
||||||
'user' => trim((string)($_POST['db_user'] ?? '')),
|
|
||||||
'password' => (string)($_POST['db_pass'] ?? ''),
|
|
||||||
];
|
|
||||||
if ($dbp['dbname'] === '') {
|
|
||||||
$err = '请填写数据库名。';
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
Db::saveParams($dbp);
|
|
||||||
// 重新建立连接并建表
|
|
||||||
$pdo = Db::connect($dbp);
|
|
||||||
Db::migrate();
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$err = '数据库连接/初始化失败:' . $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($err === '') {
|
|
||||||
// 2) 写入系统配置(DB 后端写 settings['config'],文件后端写 config.json)
|
|
||||||
$cfg = ny_default_config();
|
|
||||||
$cfg['base_url'] = $base;
|
|
||||||
$cfg['api_prefix'] = $prefix;
|
|
||||||
$cfg['username'] = $user;
|
|
||||||
$cfg['password'] = $pwd;
|
|
||||||
$cfg['verify_ssl'] = $verify;
|
|
||||||
$cfg['full_score'] = $full;
|
|
||||||
$saved = ny_save_config($cfg);
|
|
||||||
if ($saved) {
|
|
||||||
// 清理 token 缓存(连接信息变化)
|
|
||||||
@unlink(NY_DATA . '/cache/token.json');
|
|
||||||
$ok = true;
|
|
||||||
} else {
|
|
||||||
if ($backend === 'file') {
|
|
||||||
$err = '无法写入文件配置,请检查 data/ 目录权限。';
|
|
||||||
} else {
|
|
||||||
$err = '配置写入失败(已建表但写入配置异常)。';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = ny_load_config();
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>首次安装 · <?= htmlspecialchars(NY_APP_NAME) ?></title>
|
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
|
||||||
<style>
|
|
||||||
.install-wrap { position:fixed; inset:0; z-index:200; display:flex; align-items:center; justify-content:center; padding:20px; background:linear-gradient(135deg,#1b2440,#2f6bff); overflow:auto; }
|
|
||||||
.install-box { width:640px; max-width:100%; background:#fff; border-radius:18px; padding:28px 28px 30px; box-shadow:0 24px 64px rgba(15,23,42,.35); }
|
|
||||||
.install-box::before { content:''; position:absolute; }
|
|
||||||
.steps { display:flex; gap:8px; margin:6px 0 20px; }
|
|
||||||
.steps i { flex:1; height:4px; border-radius:999px; background:#e2e8f2; }
|
|
||||||
.steps i.on { background:linear-gradient(90deg,#2f6bff,#6a4bff); }
|
|
||||||
.opt-seg { display:flex; gap:10px; }
|
|
||||||
.opt-seg label { flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--border); border-radius:10px; padding:12px 8px; cursor:pointer; font-size:13px; font-weight:600; color:var(--text-2); transition:.15s; }
|
|
||||||
.opt-seg label.on { border-color:var(--primary); background:var(--primary-weak); color:var(--primary); }
|
|
||||||
.opt-seg input { display:none; }
|
|
||||||
.db-box { margin-top:14px; padding:14px; border:1px dashed #cfd8ea; border-radius:10px; background:#fbfcfe; }
|
|
||||||
.db-box.hidden { display:none; }
|
|
||||||
.form-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
||||||
.fi.full { grid-column:1 / -1; }
|
|
||||||
.banner { padding:12px 14px; border-radius:10px; font-size:12.5px; margin-bottom:16px; line-height:1.6; }
|
|
||||||
.banner.err { background:var(--danger-weak); color:#a3342c; border:1px solid #f5d3cf; }
|
|
||||||
.banner.ok { background:var(--ok-weak); color:#0d7a4d; border:1px solid #c8ecda; }
|
|
||||||
.forgot { display:flex; justify-content:flex-end; margin-top:6px; font-size:12px; color:var(--text-3); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="dash">
|
|
||||||
<div class="install-wrap">
|
|
||||||
<div class="install-box">
|
|
||||||
<div class="login-brand">
|
|
||||||
<span class="logo">NY</span>
|
|
||||||
<div class="brand-text">
|
|
||||||
<strong><?= htmlspecialchars(NY_APP_NAME) ?></strong>
|
|
||||||
<small>首次安装 · 配置数据存储与系统连接</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="steps"><i class="on"></i><i class="on"></i></div>
|
|
||||||
|
|
||||||
<?php if ($ok): ?>
|
|
||||||
<div class="banner ok">✅ 安装完成!即将进入登录页…</div>
|
|
||||||
<p style="font-size:13px;color:var(--text-2);text-align:center;margin:24px 0 6px">
|
|
||||||
若连接信息有误或未生效,请重新访问本页配置。</p>
|
|
||||||
<div class="login-actions"><button class="btn btn-primary" onclick="location.href='login.php'">前往登录</button></div>
|
|
||||||
<?php else: ?>
|
|
||||||
<form method="post" class="form-grid" id="f">
|
|
||||||
<?php if ($err !== ''): ?><div class="banner err full"><?= htmlspecialchars($err) ?></div><?php endif; ?>
|
|
||||||
|
|
||||||
<div class="fi full">
|
|
||||||
<label>数据存储后端</label>
|
|
||||||
<div class="opt-seg" id="seg">
|
|
||||||
<label><input type="radio" name="backend" value="file" checked><span>✅ 文件存储</span><small style="font-weight:500;color:var(--text-3)">无需数据库,开箱即用</small></label>
|
|
||||||
<label><input type="radio" name="backend" value="pgsql"><span>🐘 PostgreSQL</span><small style="font-weight:500;color:var(--text-3)">生产环境推荐</small></label>
|
|
||||||
<label><input type="radio" name="backend" value="mysql"><span>🍀 MariaDB / MySQL</span><small style="font-weight:500;color:var(--text-3)">兼容 MySQL 协议</small></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="db-box hidden full" id="dbBox">
|
|
||||||
<div class="form-grid2">
|
|
||||||
<div class="fi"><label>主机</label><input type="text" name="db_host" value="127.0.0.1"></div>
|
|
||||||
<div class="fi"><label>端口</label><input type="number" id="db_port" name="db_port" value="5432"></div>
|
|
||||||
<div class="fi"><label>数据库名</label><input type="text" name="db_name"></div>
|
|
||||||
<div class="fi"><label>账号</label><input type="text" name="db_user"></div>
|
|
||||||
<div class="fi full"><label>密码</label><input type="password" name="db_pass"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fi full">
|
|
||||||
<label>卷王系统地址</label>
|
|
||||||
<input type="text" name="base_url" value="<?= htmlspecialchars($cfg['base_url']) ?>" placeholder="https://exam.nxxy335.top">
|
|
||||||
</div>
|
|
||||||
<div class="fi">
|
|
||||||
<label>接口前缀</label>
|
|
||||||
<input type="text" name="api_prefix" value="<?= htmlspecialchars($cfg['api_prefix']) ?>" placeholder="/api">
|
|
||||||
</div>
|
|
||||||
<div class="fi">
|
|
||||||
<label>默认满分</label>
|
|
||||||
<input type="number" name="full_score" min="1" max="1000" value="<?= (int)$cfg['full_score'] ?>">
|
|
||||||
<p class="hint">用于合格率(≥60%满分),各试卷可在设置中单独调整</p>
|
|
||||||
</div>
|
|
||||||
<div class="fi">
|
|
||||||
<label>登录账号</label>
|
|
||||||
<input type="text" name="username" value="<?= htmlspecialchars($cfg['username']) ?>" placeholder="admin">
|
|
||||||
</div>
|
|
||||||
<div class="fi">
|
|
||||||
<label>登录密码</label>
|
|
||||||
<input type="password" name="password" placeholder="请输入密码">
|
|
||||||
</div>
|
|
||||||
<div class="fi full checks">
|
|
||||||
<label><input type="checkbox" name="verify_ssl" <?= $cfg['verify_ssl'] ? 'checked' : '' ?>> 校验 HTTPS 证书</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="full" style="margin-top:10px">
|
|
||||||
<button class="btn btn-primary" id="btnInstall" style="width:100%;height:44px;font-size:15px">⚙️ 完成安装</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var seg = document.getElementById('seg');
|
|
||||||
var dbBox = document.getElementById('dbBox');
|
|
||||||
var dbPort = document.getElementById('db_port');
|
|
||||||
function sync(){
|
|
||||||
var v = (document.querySelector('input[name=backend]:checked')||{}).value || 'file';
|
|
||||||
var labels = seg.querySelectorAll('label');
|
|
||||||
labels.forEach(function(l){ l.classList.toggle('on', l.querySelector('input').value === v); });
|
|
||||||
dbBox.classList.toggle('hidden', v === 'file');
|
|
||||||
if (v === 'pgsql') dbPort.value = 5432;
|
|
||||||
if (v === 'mysql') dbPort.value = 3306;
|
|
||||||
}
|
|
||||||
seg.addEventListener('change', sync);
|
|
||||||
sync();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 数据结果缓存(短期 TTL)
|
||||||
|
*
|
||||||
|
* 目的:拉取卷王全量答卷是重操作(多次 HTTP + 大量 json_decode),
|
||||||
|
* 多个老师同时查看同一套卷、或页面按 5~15 秒自动刷新时,会重复打卷王。
|
||||||
|
* 这里把 DataService::build() 的结果按「项目 + 配置 + 名单指纹」缓存一小段时间。
|
||||||
|
*
|
||||||
|
* 失效保证:名单变化(Roster::fingerprint)、连接/去重等配置变化都会改变 key。
|
||||||
|
* 另外可显式 force=1 跳过缓存(前端"立即刷新"按钮使用)。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class DataCache
|
||||||
|
{
|
||||||
|
private static function dir(): string
|
||||||
|
{
|
||||||
|
return NY_DATA . '/cache';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由若干要素拼成缓存 key */
|
||||||
|
public static function key(array $parts): string
|
||||||
|
{
|
||||||
|
return md5(implode("\x1f", array_map(static fn($v) => (string)$v, $parts)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function path(string $key): string
|
||||||
|
{
|
||||||
|
return self::dir() . '/data_' . $key . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lockPath(string $key): string
|
||||||
|
{
|
||||||
|
return self::dir() . '/data_' . $key . '.lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取缓存;不存在 / 过期 / 损坏都返回 null
|
||||||
|
* @return array{at:int,result:array}|null
|
||||||
|
*/
|
||||||
|
public static function read(string $key, int $ttl): ?array
|
||||||
|
{
|
||||||
|
$f = self::path($key);
|
||||||
|
if (!is_file($f)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = @file_get_contents($f);
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
if (!is_array($d) || !isset($d['at']) || !is_array($d['result'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($ttl > 0 && (time() - (int)$d['at']) > $ttl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['at' => (int)$d['at'], 'result' => $d['result']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function write(string $key, array $result): bool
|
||||||
|
{
|
||||||
|
if (!is_dir(self::dir())) {
|
||||||
|
@mkdir(self::dir(), 0775, true);
|
||||||
|
}
|
||||||
|
$tmp = self::path($key) . '.tmp';
|
||||||
|
$ok = @file_put_contents($tmp, json_encode(
|
||||||
|
['at' => time(), 'result' => $result],
|
||||||
|
JSON_UNESCAPED_UNICODE
|
||||||
|
), LOCK_EX);
|
||||||
|
if ($ok === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return @rename($tmp, self::path($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取构建锁(防止并发惊群:同一 key 只有一个请求真正去拉卷王)。
|
||||||
|
*
|
||||||
|
* @return resource|null 拿到锁返回句柄(需配合 releaseLock 释放);拿不到返回 null
|
||||||
|
*/
|
||||||
|
public static function acquireLock(string $key)
|
||||||
|
{
|
||||||
|
if (!is_dir(self::dir())) {
|
||||||
|
@mkdir(self::dir(), 0775, true);
|
||||||
|
}
|
||||||
|
$fh = @fopen(self::lockPath($key), 'c');
|
||||||
|
if ($fh === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!flock($fh, LOCK_EX | LOCK_NB)) {
|
||||||
|
fclose($fh);
|
||||||
|
return null; // 已有其它请求在构建
|
||||||
|
}
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function releaseLock($fh): void
|
||||||
|
{
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
@flock($fh, LOCK_UN);
|
||||||
|
@fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -30,9 +30,15 @@ class DataService
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// children 在根或子节点
|
// 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
|
||||||
$self = $node;
|
$self = $node;
|
||||||
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
|
$children = null;
|
||||||
|
foreach (['children', 'questions', 'list', 'items'] as $ck) {
|
||||||
|
if (isset($node[$ck]) && is_array($node[$ck])) {
|
||||||
|
$children = $node[$ck];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$id = isset($self['id']) ? (string)$self['id'] : '';
|
$id = isset($self['id']) ? (string)$self['id'] : '';
|
||||||
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
||||||
@@ -79,24 +85,75 @@ class DataService
|
|||||||
/**
|
/**
|
||||||
* 组装结果
|
* 组装结果
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
|
||||||
|
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
|
||||||
|
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
|
||||||
|
*
|
||||||
|
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
|
||||||
|
*/
|
||||||
|
public static function projectConfig(string $projectId): array
|
||||||
|
{
|
||||||
|
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||||
|
if ($projectId === '') {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
return Db::projectConfigGet($projectId);
|
||||||
|
}
|
||||||
|
// 文件回退
|
||||||
|
$all = [];
|
||||||
|
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$all = $saved;
|
||||||
|
}
|
||||||
|
$one = $all[$projectId] ?? null;
|
||||||
|
return is_array($one) ? array_merge($def, $one) : $def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入某份试卷的独立配置 */
|
||||||
|
public static function projectConfigSave(string $projectId, array $cfg): void
|
||||||
|
{
|
||||||
|
if ($projectId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
Db::projectConfigSet($projectId, $cfg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$all = [];
|
||||||
|
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||||
|
if (is_array($saved)) {
|
||||||
|
$all = $saved;
|
||||||
|
}
|
||||||
|
$all[$projectId] = $cfg;
|
||||||
|
ny_write_json(NY_DATA . '/project_config.json', $all);
|
||||||
|
}
|
||||||
|
|
||||||
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
||||||
{
|
{
|
||||||
$client = new SurveyKingClient($cfg);
|
$client = new SurveyKingClient($cfg);
|
||||||
|
|
||||||
$students = Roster::all();
|
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
|
||||||
// 姓名 -> 学号 / 班级(规范化)
|
$pCfg = self::projectConfig($projectId);
|
||||||
$studentInfo = [];
|
$exempt = [];
|
||||||
foreach ($students as $s) {
|
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
|
||||||
$k = ny_norm_name((string)$s['name']);
|
$sid = trim((string)$sid);
|
||||||
if ($k !== '') {
|
if ($sid !== '') {
|
||||||
$studentInfo[$k] = [
|
$exempt[$sid] = true;
|
||||||
'sid' => (string)$s['sid'],
|
|
||||||
'cls' => (string)($s['cls'] ?? ''),
|
|
||||||
'grp' => (string)($s['grp'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 基础名单(全名单):用于答卷匹配,不受白名单影响
|
||||||
|
$rosterAll = Roster::all();
|
||||||
|
if ($exempt !== []) {
|
||||||
|
$rosterAll = array_values(array_filter(
|
||||||
|
$rosterAll,
|
||||||
|
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
|
||||||
|
|
||||||
$project = $client->getProject($projectId);
|
$project = $client->getProject($projectId);
|
||||||
$survey = $project['survey'] ?? null;
|
$survey = $project['survey'] ?? null;
|
||||||
if (is_string($survey) && $survey !== '') {
|
if (is_string($survey) && $survey !== '') {
|
||||||
@@ -105,6 +162,75 @@ class DataService
|
|||||||
$survey = $decoded;
|
$survey = $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
|
||||||
|
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||||||
|
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
|
||||||
|
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
|
||||||
|
$paperWhitelist = [];
|
||||||
|
try {
|
||||||
|
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$paperWhitelist = [];
|
||||||
|
}
|
||||||
|
$useWhitelist = ($paperWhitelist !== []);
|
||||||
|
$whitelistTotal = 0;
|
||||||
|
|
||||||
|
if ($useWhitelist) {
|
||||||
|
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
|
||||||
|
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
|
||||||
|
$rosterMap = [];
|
||||||
|
foreach ($rosterAll as $s) {
|
||||||
|
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||||||
|
$login = strtolower(trim((string)($s['login'] ?? '')));
|
||||||
|
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||||||
|
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
|
||||||
|
if ($login) { $rosterMap['login:' . $login] = $s; }
|
||||||
|
if ($name) { $rosterMap['name:' . $name] = $s; }
|
||||||
|
}
|
||||||
|
$students = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($paperWhitelist as $v) {
|
||||||
|
$key = strtolower(trim((string)$v));
|
||||||
|
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
|
||||||
|
if ($m !== null) {
|
||||||
|
$sid = trim((string)($m['sid'] ?? ''));
|
||||||
|
if ($sid !== '' && isset($seen[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($sid !== '') { $seen[$sid] = true; }
|
||||||
|
$students[] = $m;
|
||||||
|
} else {
|
||||||
|
if (isset($seen['wk:' . $key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen['wk:' . $key] = true;
|
||||||
|
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$whitelistTotal = count($students);
|
||||||
|
} else {
|
||||||
|
$students = $rosterAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
|
||||||
|
// 保证所有提交(含白名单外提交)都能匹配到姓名。
|
||||||
|
$studentInfo = [];
|
||||||
|
$addInfo = static function (array $s) use (&$studentInfo): void {
|
||||||
|
$k = ny_norm_name((string)($s['name'] ?? ''));
|
||||||
|
if ($k !== '') {
|
||||||
|
$studentInfo[$k] = [
|
||||||
|
'sid' => (string)$s['sid'],
|
||||||
|
'cls' => (string)($s['cls'] ?? ''),
|
||||||
|
'grp' => (string)($s['grp'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach ($rosterAll as $s) { $addInfo($s); }
|
||||||
|
if ($useWhitelist) {
|
||||||
|
foreach ($students as $s) { $addInfo($s); }
|
||||||
|
}
|
||||||
|
|
||||||
$questions = self::flattenQuestions($survey);
|
$questions = self::flattenQuestions($survey);
|
||||||
|
|
||||||
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
||||||
@@ -128,9 +254,8 @@ class DataService
|
|||||||
$includeTemp = !empty($cfg['include_temp_save']);
|
$includeTemp = !empty($cfg['include_temp_save']);
|
||||||
$dedupe = !empty($cfg['dedupe']);
|
$dedupe = !empty($cfg['dedupe']);
|
||||||
|
|
||||||
$rows = [];
|
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
|
||||||
$unmatched = [];
|
$unmatched = [];
|
||||||
$seenSid = [];
|
|
||||||
|
|
||||||
foreach ($answers as $a) {
|
foreach ($answers as $a) {
|
||||||
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
||||||
@@ -157,7 +282,7 @@ class DataService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$v = ny_flatten_value($answerMap[$qid]);
|
$v = ny_flatten_value($answerMap[$qid]);
|
||||||
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
|
if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
|
||||||
$name = $v;
|
$name = $v;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -166,7 +291,7 @@ class DataService
|
|||||||
if ($name === '' && $questions === []) {
|
if ($name === '' && $questions === []) {
|
||||||
foreach ($answerMap as $v) {
|
foreach ($answerMap as $v) {
|
||||||
$s = ny_flatten_value($v);
|
$s = ny_flatten_value($v);
|
||||||
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
|
if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
|
||||||
$name = $s;
|
$name = $s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -191,8 +316,8 @@ class DataService
|
|||||||
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 考试时长
|
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
|
||||||
$ai = $a['metaInfo']['answerInfo'] ?? [];
|
$ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
|
||||||
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
||||||
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
||||||
$dur = null;
|
$dur = null;
|
||||||
@@ -226,22 +351,22 @@ class DataService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dedupe && isset($seenSid[$sid])) {
|
$rawRows[] = $row;
|
||||||
$idx = $seenSid[$sid];
|
|
||||||
// 保留提交时间最新的一份
|
|
||||||
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
|
|
||||||
$rows[$idx] = $row;
|
|
||||||
$seenSid[$sid] = $idx;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$seenSid[$sid] = count($rows);
|
|
||||||
$rows[] = $row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
|
||||||
|
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
|
||||||
|
$agg = self::aggregateByStudent($rawRows, $dedupe);
|
||||||
|
$rows = $agg['rows'];
|
||||||
|
$attemptsTotal = $agg['attempts_total'];
|
||||||
|
|
||||||
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
||||||
usort($rows, static function (array $x, array $y): int {
|
// 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
|
||||||
$d = ($y['submit_ts'] ?? 0) <=> ($x['submit_ts'] ?? 0);
|
// 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
|
||||||
|
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
|
||||||
|
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
|
||||||
|
usort($rows, static function (array $x, array $y) use ($sortTs): int {
|
||||||
|
$d = $sortTs($y) <=> $sortTs($x);
|
||||||
if ($d !== 0) {
|
if ($d !== 0) {
|
||||||
return $d;
|
return $d;
|
||||||
}
|
}
|
||||||
@@ -292,6 +417,7 @@ class DataService
|
|||||||
'min_score' => $scores !== [] ? min($scores) : null,
|
'min_score' => $scores !== [] ? min($scores) : null,
|
||||||
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
||||||
'answer_total' => count($answers),
|
'answer_total' => count($answers),
|
||||||
|
'attempts_total'=> $attemptsTotal,
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -309,7 +435,270 @@ class DataService
|
|||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'roster' => $students,
|
'roster' => $students,
|
||||||
'roster_count' => count($students),
|
'roster_count' => count($students),
|
||||||
|
'whitelist_driven' => $useWhitelist,
|
||||||
|
'whitelist_total' => $whitelistTotal,
|
||||||
|
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||||
|
'project_cfg' => [
|
||||||
|
'full_score' => $pCfg['full_score'] ?? null,
|
||||||
|
'pass_rate' => $pCfg['pass_rate'] ?? null,
|
||||||
|
'exempt' => array_values(array_map('strval', array_keys($exempt))),
|
||||||
|
],
|
||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
||||||
|
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
|
||||||
|
*
|
||||||
|
* @param array $rows 聚合后的行(每行含 attempts_list)
|
||||||
|
* @param bool $desc 是否按提交时间降序(新 -> 旧)
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function expandAttempts(array $rows, bool $desc = true): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$list = (array)($r['attempts_list'] ?? []);
|
||||||
|
if ($list === []) {
|
||||||
|
// 没有明细时退回代表行本身
|
||||||
|
$one = $r;
|
||||||
|
$one['idx'] = 1;
|
||||||
|
$one['is_best'] = true;
|
||||||
|
$one['day'] = self::dayKeyOf((int)($r['submit_ts'] ?? 0), (string)($r['submit_at'] ?? ''));
|
||||||
|
$one['day_best'] = true;
|
||||||
|
$one['day_count'] = 1;
|
||||||
|
$out[] = $one;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($list as $a) {
|
||||||
|
$out[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
|
'score' => $a['score'] ?? null,
|
||||||
|
'submit_ts' => (int)($a['submit_ts'] ?? 0),
|
||||||
|
'submit_at' => (string)($a['submit_at'] ?? '-'),
|
||||||
|
'duration' => $a['duration'] ?? null,
|
||||||
|
'duration_txt' => (string)($a['duration_txt'] ?? '-'),
|
||||||
|
'answer_id' => (string)($a['answer_id'] ?? ''),
|
||||||
|
'temp_save' => !empty($a['temp_save']),
|
||||||
|
'attempts' => (int)($r['attempts'] ?? 1),
|
||||||
|
'best_score' => $r['best_score'] ?? null,
|
||||||
|
'idx' => (int)($a['idx'] ?? 1),
|
||||||
|
'is_best' => !empty($a['is_best']),
|
||||||
|
'day' => (string)($a['day'] ?? ''),
|
||||||
|
'day_best' => !empty($a['day_best']),
|
||||||
|
'day_count' => (int)($a['day_count'] ?? 1),
|
||||||
|
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
|
||||||
|
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($out, static function (array $x, array $y) use ($desc): int {
|
||||||
|
// 注意:x<=>y 是升序,降序要用 y<=>x
|
||||||
|
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
|
||||||
|
if (!$desc) {
|
||||||
|
$d = -$d;
|
||||||
|
}
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
// 同时间:学号升序
|
||||||
|
$a = (string)$x['sid'];
|
||||||
|
$b = (string)$y['sid'];
|
||||||
|
if (is_numeric($a) && is_numeric($b)) {
|
||||||
|
return $a <=> $b;
|
||||||
|
}
|
||||||
|
return strnatcmp($a, $b);
|
||||||
|
});
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出/展示用的排序:
|
||||||
|
* - time(默认):按提交时间降序,同时间按学号升序
|
||||||
|
* - sid:按学号升序,同一学号内多次考试再按提交时间降序
|
||||||
|
*
|
||||||
|
* @param array $rows expandAttempts 展开后的行
|
||||||
|
* @param string $mode 'time' | 'sid'
|
||||||
|
*/
|
||||||
|
public static function sortForExport(array $rows, string $mode = 'time'): array
|
||||||
|
{
|
||||||
|
$out = $rows;
|
||||||
|
if ($mode === 'sid') {
|
||||||
|
usort($out, static function ($x, $y): int {
|
||||||
|
$a = (string)($x['sid'] ?? '');
|
||||||
|
$b = (string)($y['sid'] ?? '');
|
||||||
|
if (is_numeric($a) && is_numeric($b)) {
|
||||||
|
$d = $a <=> $b;
|
||||||
|
} else {
|
||||||
|
$d = strnatcmp($a, $b);
|
||||||
|
}
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
|
||||||
|
});
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
usort($out, static function ($x, $y): int {
|
||||||
|
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
$a = (string)($x['sid'] ?? '');
|
||||||
|
$b = (string)($y['sid'] ?? '');
|
||||||
|
if (is_numeric($a) && is_numeric($b)) {
|
||||||
|
return $a <=> $b;
|
||||||
|
}
|
||||||
|
return strnatcmp($a, $b);
|
||||||
|
});
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某次提交的「年月日」分组键。
|
||||||
|
*
|
||||||
|
* 关键:一律用服务端本地时区 date('Y-m-d', $ts) 计算,**不能用 UTC**(gmdate / JS toISOString),
|
||||||
|
* 否则零点前后提交的答卷会被算到前一天/后一天(东八区会整体错一天)。
|
||||||
|
* 无有效时间戳(createAt 缺失导致 ny_to_timestamp 返回 0)时,退回从显示串取前 10 位;
|
||||||
|
* 仍取不到则返回空串,上层归为「未知时间」分组并排在最后。
|
||||||
|
*
|
||||||
|
* @param int $ts 提交时间戳(秒),0 表示未知
|
||||||
|
* @param string $submitAt 展示用时间串(形如 2026-09-08 09:41:20)
|
||||||
|
*/
|
||||||
|
public static function dayKeyOf(int $ts, string $submitAt = ''): string
|
||||||
|
{
|
||||||
|
if ($ts > 0) {
|
||||||
|
return date('Y-m-d', $ts);
|
||||||
|
}
|
||||||
|
if ($submitAt !== '' && $submitAt !== '-' && preg_match('/^(\d{4}-\d{2}-\d{2})/', $submitAt, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
|
||||||
|
*
|
||||||
|
* 关键语义(修复"考试次数=1 / 历史只有一条"的 bug):
|
||||||
|
* - attempts(真实考试次数) 与 attempts_list(每次提交明细) **始终等于全部提交**,与 dedupe 无关;
|
||||||
|
* 这样"考试次数列"与"点击查看历史"永远展示完整数据。
|
||||||
|
* - dedupe 仅决定主明细表"代表成绩"那一行取哪次考试的成绩:
|
||||||
|
* dedupe=true → 取提交时间最新的一份(默认,看板更干净)
|
||||||
|
* dedupe=false → 取最高分(同分时取最早达到该分数的那次)
|
||||||
|
*
|
||||||
|
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
|
||||||
|
* @return array{rows:array, attempts_total:int}
|
||||||
|
*/
|
||||||
|
public static function aggregateByStudent(array $rows, bool $dedupe): array
|
||||||
|
{
|
||||||
|
$bySid = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$bySid[(string)($r['sid'] ?? '')][] = $r;
|
||||||
|
}
|
||||||
|
$agg = [];
|
||||||
|
$attemptsTotal = 0;
|
||||||
|
foreach ($bySid as $sid => $list) {
|
||||||
|
$attemptsTotal += count($list);
|
||||||
|
// 升序:旧 -> 新
|
||||||
|
$sorted = $list;
|
||||||
|
usort($sorted, static function (array $x, array $y): int {
|
||||||
|
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
|
||||||
|
});
|
||||||
|
// 最佳成绩(用于标记每次考试是否为最高分)
|
||||||
|
$maxScore = null;
|
||||||
|
foreach ($sorted as $a) {
|
||||||
|
if ($a['score'] !== null && ($maxScore === null || $a['score'] > $maxScore)) {
|
||||||
|
$maxScore = $a['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 最近一次提交(升序排序后的最后一份)
|
||||||
|
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
|
||||||
|
|
||||||
|
// 按「提交时间年月日」分组统计:每天的最高分与提交次数。
|
||||||
|
// 历史弹窗据此按天分类,并在每个分类内标记当日最高分。
|
||||||
|
$dayMax = []; // day => 当天最高分
|
||||||
|
$dayCount = []; // day => 当天提交次数(含未判分)
|
||||||
|
foreach ($sorted as $a) {
|
||||||
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
|
$dayCount[$day] = ($dayCount[$day] ?? 0) + 1;
|
||||||
|
if ($a['score'] !== null && (!array_key_exists($day, $dayMax) || $a['score'] > $dayMax[$day])) {
|
||||||
|
$dayMax[$day] = $a['score'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
|
||||||
|
$bestMarked = false;
|
||||||
|
// 每天是否已标记过"当日最高"(同分时同样只标最早达到的那次,与全局 is_best 一致)
|
||||||
|
$dayMarked = [];
|
||||||
|
$idx = 0;
|
||||||
|
$attemptsList = array_map(
|
||||||
|
static function (array $a) use (&$idx, $maxScore, &$bestMarked, &$dayMarked, $dayMax, $dayCount): array {
|
||||||
|
$idx++;
|
||||||
|
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
|
||||||
|
if ($isBest) {
|
||||||
|
$bestMarked = true;
|
||||||
|
}
|
||||||
|
$day = self::dayKeyOf((int)($a['submit_ts'] ?? 0), (string)($a['submit_at'] ?? ''));
|
||||||
|
$dayBest = ($a['score'] !== null && array_key_exists($day, $dayMax)
|
||||||
|
&& (float)$a['score'] === (float)$dayMax[$day] && empty($dayMarked[$day]));
|
||||||
|
if ($dayBest) {
|
||||||
|
$dayMarked[$day] = true;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'idx' => $idx,
|
||||||
|
'is_best' => $isBest,
|
||||||
|
'submit_at' => $a['submit_at'],
|
||||||
|
'submit_ts' => $a['submit_ts'],
|
||||||
|
'score' => $a['score'],
|
||||||
|
'duration' => $a['duration'],
|
||||||
|
'duration_txt' => $a['duration_txt'],
|
||||||
|
'answer_id' => $a['answer_id'],
|
||||||
|
'temp_save' => $a['temp_save'],
|
||||||
|
// 按天分类用:day 为空串表示时间未知
|
||||||
|
'day' => $day,
|
||||||
|
'day_best' => $dayBest,
|
||||||
|
'day_count' => (int)($dayCount[$day] ?? 1),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$sorted
|
||||||
|
);
|
||||||
|
|
||||||
|
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
|
||||||
|
if ($dedupe) {
|
||||||
|
// 代表成绩 = 提交时间最新的一份(默认,看板更干净)
|
||||||
|
$row = $sorted[count($sorted) - 1];
|
||||||
|
} else {
|
||||||
|
// 代表成绩 = 最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
|
||||||
|
$best = null;
|
||||||
|
$bestScore = null;
|
||||||
|
foreach ($sorted as $a) {
|
||||||
|
if ($a['score'] !== null && ($bestScore === null || $a['score'] > $bestScore)) {
|
||||||
|
$bestScore = $a['score'];
|
||||||
|
$best = $a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($best === null) {
|
||||||
|
$best = $sorted[count($sorted) - 1];
|
||||||
|
}
|
||||||
|
$row = $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键修复:考试次数与历史明细始终使用真实全部提交,与去重无关
|
||||||
|
// (之前 dedupe=true 时把 attempts 写成 1、attempts_list 只留最新一份,导致"考试次数"和
|
||||||
|
// 历史弹窗都只显示 1 条,多次刷题的数据被吞掉)
|
||||||
|
$row['attempts'] = count($list);
|
||||||
|
$row['best_score'] = $maxScore; // 真实最高分(跨全部尝试),用于历史弹窗标题展示
|
||||||
|
$row['attempts_list'] = $attemptsList;
|
||||||
|
// 最近一次提交时间(与"代表成绩"可能是不同的一次考试):
|
||||||
|
// 用于"按提交时间排序"和"新提交高亮",保证重考(哪怕分数更低)也能被发现
|
||||||
|
$row['last_submit_ts'] = $lastSubmitTs;
|
||||||
|
$row['last_submit_at'] = $lastSubmitTs > 0 ? date('Y-m-d H:i:s', $lastSubmitTs) : '-';
|
||||||
|
$agg[] = $row;
|
||||||
|
}
|
||||||
|
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,313 +1,387 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 数据库连接层(统一 PDO)
|
* 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
|
||||||
*
|
*
|
||||||
* 支持 PostgreSQL / MariaDB(MySQL) / SQLite(主要用于本地无 DB 时的降级与测试)。
|
* 设计要点:
|
||||||
* 连接参数存于 data/db.json;通过 Db::instance() 取单例连接。
|
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
|
||||||
* 未配置数据库时返回 null,上层回退到文件存储(config.json / roster.json)。
|
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
|
||||||
|
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
|
||||||
|
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
|
||||||
|
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
|
||||||
|
* 老数据不丢(迁移只发生在目标表为空时)。
|
||||||
|
*
|
||||||
|
* 表结构:
|
||||||
|
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
|
||||||
|
* ny_roster (sid TEXT PK, name, cls, grp, login)
|
||||||
|
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
class Db
|
class Db
|
||||||
{
|
{
|
||||||
/** @var PDO|null */
|
private static ?PDO $pdo = null;
|
||||||
private static $pdo = null;
|
private static string $err = '';
|
||||||
private static string $driver = '';
|
private static bool $initTried = false;
|
||||||
private static bool $tried = false;
|
|
||||||
private static bool $failed = false;
|
|
||||||
|
|
||||||
/** 数据库连接参数文件 */
|
|
||||||
public static function dbFile(): string
|
|
||||||
{
|
|
||||||
return NY_DATA . '/db.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否已配置数据库(db.json 存在且有 driver/host 等) */
|
|
||||||
public static function isConfigured(): bool
|
|
||||||
{
|
|
||||||
$d = self::params();
|
|
||||||
return !empty($d['driver']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取连接参数 */
|
|
||||||
public static function params(): array
|
|
||||||
{
|
|
||||||
$d = ny_read_json(self::dbFile(), null);
|
|
||||||
if (!is_array($d)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return $d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 写入连接参数 */
|
|
||||||
public static function saveParams(array $p): bool
|
|
||||||
{
|
|
||||||
return ny_write_json(self::dbFile(), $p);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前驱动名(用于 SQL 方言):mysql | pgsql | sqlite | '' */
|
|
||||||
public static function driver(): string
|
|
||||||
{
|
|
||||||
self::check();
|
|
||||||
return self::$driver;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isConnected(): bool
|
|
||||||
{
|
|
||||||
self::check();
|
|
||||||
return self::$pdo !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 PDO 单例;未配置或连不上返回 null。
|
|
||||||
*/
|
|
||||||
public static function pdo(): ?PDO
|
|
||||||
{
|
|
||||||
self::check();
|
|
||||||
return self::$pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function check(): void
|
|
||||||
{
|
|
||||||
if (self::$tried) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self::$tried = true;
|
|
||||||
$p = self::params();
|
|
||||||
$driver = strtolower((string)($p['driver'] ?? ''));
|
|
||||||
if ($driver === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self::$driver = $driver;
|
|
||||||
try {
|
|
||||||
self::$pdo = self::connect($p);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
self::$failed = true;
|
|
||||||
self::$pdo = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据参数建 PDO 连接 */
|
|
||||||
public static function connect(array $p): PDO
|
|
||||||
{
|
|
||||||
$driver = strtolower((string)($p['driver'] ?? ''));
|
|
||||||
$host = (string)($p['host'] ?? '127.0.0.1');
|
|
||||||
$port = (int)($p['port'] ?? 0);
|
|
||||||
$db = (string)($p['dbname'] ?? ($p['database'] ?? ''));
|
|
||||||
$user = (string)($p['user'] ?? ($p['username'] ?? ''));
|
|
||||||
$pass = (string)($p['password'] ?? '');
|
|
||||||
|
|
||||||
$options = [
|
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($driver === 'sqlite') {
|
|
||||||
$dsn = 'sqlite:' . ($p['path'] ?? ($db ?: (NY_DATA . '/app.sqlite')));
|
|
||||||
} elseif ($driver === 'pgsql') {
|
|
||||||
$dsn = 'pgsql:host=' . $host;
|
|
||||||
if ($port > 0) {
|
|
||||||
$dsn .= ';port=' . $port;
|
|
||||||
}
|
|
||||||
$dsn .= ';dbname=' . $db;
|
|
||||||
$dsn .= ';user=' . $user;
|
|
||||||
$dsn .= ';password=' . $pass;
|
|
||||||
} else { // mysql / mariadb
|
|
||||||
$dsn = 'mysql:host=' . $host;
|
|
||||||
if ($port > 0) {
|
|
||||||
$dsn .= ';port=' . $port;
|
|
||||||
}
|
|
||||||
$dsn .= ';dbname=' . $db . ';charset=utf8mb4';
|
|
||||||
$options[PDO::ATTR_PERSISTENT] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
|
||||||
if ($driver === 'mysql') {
|
|
||||||
$pdo->exec("SET NAMES utf8mb4");
|
|
||||||
}
|
|
||||||
return $pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 表名前缀 */
|
|
||||||
public static function prefix(): string
|
public static function prefix(): string
|
||||||
{
|
{
|
||||||
return 'ny_';
|
return 'ny_';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 数据文件路径 */
|
||||||
* 建表(幂等)。返回已执行语句数量。
|
public static function dbFile(): string
|
||||||
* 用通用 SQL,三方言均可执行(表名/列名小写、规范命名)。
|
{
|
||||||
*/
|
return NY_DATA . '/ny.db';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否具备启用条件(有 pdo_sqlite 扩展) */
|
||||||
|
public static function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return extension_loaded('pdo_sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已真正连上 */
|
||||||
|
public static function isConnected(): bool
|
||||||
|
{
|
||||||
|
return self::pdo() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function driver(): string
|
||||||
|
{
|
||||||
|
return self::pdo() !== null ? 'sqlite' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastError(): string
|
||||||
|
{
|
||||||
|
return self::$err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||||
|
public static function connect(array $p = []): PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo instanceof PDO) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
if (!self::isConfigured()) {
|
||||||
|
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
|
||||||
|
throw new RuntimeException(self::$err);
|
||||||
|
}
|
||||||
|
$file = self::dbFile();
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$pdo = new PDO('sqlite:' . $file, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
|
||||||
|
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||||
|
$pdo->exec('PRAGMA synchronous=NORMAL');
|
||||||
|
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||||
|
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||||
|
self::$pdo = $pdo;
|
||||||
|
self::$err = '';
|
||||||
|
self::migrate();
|
||||||
|
return $pdo;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$pdo = null;
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取连接(不可用返回 null) */
|
||||||
|
public static function pdo(): ?PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo instanceof PDO) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
if (self::$initTried) {
|
||||||
|
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
|
||||||
|
}
|
||||||
|
self::$initTried = true;
|
||||||
|
try {
|
||||||
|
return self::connect();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建表 + 索引(幂等,可重复调用) */
|
||||||
public static function migrate(): int
|
public static function migrate(): int
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::$pdo;
|
||||||
if ($pdo === null) {
|
if (!$pdo) {
|
||||||
throw new RuntimeException('数据库未连接,无法初始化表结构');
|
return 0;
|
||||||
}
|
}
|
||||||
$driver = self::$driver;
|
$p = self::prefix();
|
||||||
$n = 0;
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
|
||||||
|
k TEXT PRIMARY KEY,
|
||||||
// settings(key,value) —— 系统设置键值表
|
v TEXT NOT NULL DEFAULT '',
|
||||||
if ($driver === 'sqlite') {
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
)");
|
||||||
'k VARCHAR(128) PRIMARY KEY, ' .
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
|
||||||
'v TEXT)';
|
sid TEXT PRIMARY KEY,
|
||||||
} elseif ($driver === 'pgsql') {
|
name TEXT NOT NULL DEFAULT '',
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
cls TEXT NOT NULL DEFAULT '',
|
||||||
'k VARCHAR(128) PRIMARY KEY, ' .
|
grp TEXT NOT NULL DEFAULT '',
|
||||||
'v TEXT)';
|
login TEXT NOT NULL DEFAULT '',
|
||||||
} else {
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_settings (' .
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
'`k` VARCHAR(128) NOT NULL PRIMARY KEY, ' .
|
)");
|
||||||
'`v` TEXT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
|
||||||
}
|
project_id TEXT PRIMARY KEY,
|
||||||
$pdo->exec($sql); $n++;
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
// roster —— 名单表(学号/姓名/班级/组别/登陆账号)
|
)");
|
||||||
if ($driver === 'sqlite') {
|
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
|
||||||
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' .
|
|
||||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
|
||||||
} elseif ($driver === 'pgsql') {
|
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
|
||||||
'id SERIAL PRIMARY KEY, ' .
|
|
||||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64))';
|
|
||||||
} else {
|
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS ny_roster (' .
|
|
||||||
'id INT AUTO_INCREMENT PRIMARY KEY, ' .
|
|
||||||
'sid VARCHAR(64), name VARCHAR(128), cls VARCHAR(64), grp VARCHAR(32), login VARCHAR(64)) ' .
|
|
||||||
'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
|
||||||
}
|
|
||||||
$pdo->exec($sql); $n++;
|
|
||||||
|
|
||||||
// 旧版本升级:补 login 列
|
|
||||||
self::ensureRosterLogin();
|
self::ensureRosterLogin();
|
||||||
|
return 3;
|
||||||
return $n;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旧表升级:ny_roster 缺 login 列时自动补(三方言兼容) */
|
/** 旧库缺少 login 列时补上(幂等) */
|
||||||
public static function ensureRosterLogin(): void
|
public static function ensureRosterLogin(): void
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::$pdo;
|
||||||
if ($pdo === null) {
|
if (!$pdo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$p = self::prefix();
|
||||||
|
try {
|
||||||
|
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
$has = false;
|
$has = false;
|
||||||
if (self::$driver === 'sqlite') {
|
foreach ($cols as $c) {
|
||||||
foreach ($pdo->query('PRAGMA table_info(ny_roster)')->fetchAll() as $c) {
|
if (($c['name'] ?? '') === 'login') {
|
||||||
if (strtolower((string)$c['name']) === 'login') {
|
|
||||||
$has = true;
|
$has = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif (self::$driver === 'pgsql') {
|
|
||||||
$st = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns " .
|
|
||||||
"WHERE table_name = 'ny_roster' AND column_name = 'login'");
|
|
||||||
$st->execute();
|
|
||||||
$has = (int)$st->fetchColumn() > 0;
|
|
||||||
} else {
|
|
||||||
$has = $pdo->query("SHOW COLUMNS FROM ny_roster LIKE 'login'")->fetch() !== false;
|
|
||||||
}
|
|
||||||
if (!$has) {
|
if (!$has) {
|
||||||
$pdo->exec('ALTER TABLE ny_roster ADD COLUMN login VARCHAR(64)');
|
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 设置 ----------------
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本)
|
||||||
|
*
|
||||||
|
* 返回是否真的写成功:以前这里 void + 内部吞异常,导致"配置没落盘但上层以为成功"
|
||||||
|
* (典型后果:登录成功→下一请求仍报"未配置账号"→反复跳登录页的死循环)。
|
||||||
|
* 因此改为回读校验后返回 bool,并把失败原因放进 self::lastError()。
|
||||||
|
*/
|
||||||
|
public static function settingsSet(string $k, string $v): bool
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||||
|
);
|
||||||
|
$st->execute([$k, $v, time()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 回读校验:磁盘满 / 目录不可写 / 只读挂载时 execute 可能不报错但实际未落盘
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
|
$got = $st->fetchColumn();
|
||||||
|
if ($got === false || (string)$got !== $v) {
|
||||||
|
self::$err = '写入后回读不一致(可能磁盘已满或数据库只读)';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function settingsDelete(string $k): void
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("DELETE FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function settingsGet(string $k, string $def = ''): string
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||||
|
$st->execute([$k]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
return $v === false ? $def : (string)$v;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 名单 ----------------
|
||||||
|
|
||||||
|
public static function rosterAll(): array
|
||||||
|
{
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'sid' => (string)($r['sid'] ?? ''),
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
|
'login' => (string)($r['login'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把所有 settings 读成数组
|
* 全量替换名单(roster_save 语义)
|
||||||
|
* @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
|
||||||
*/
|
*/
|
||||||
public static function settingsAll(): array
|
public static function rosterReplace(array $students): bool
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::pdo();
|
||||||
if ($pdo === null) {
|
if (!$pdo) {
|
||||||
return [];
|
self::$err = '数据库未连接';
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
$out = [];
|
|
||||||
$stmt = $pdo->query('SELECT k, v FROM ny_settings');
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$out[$row['k']] = $row['v'];
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function settingsSet(string $k, string $v): void
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if ($pdo === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$drv = self::$driver;
|
|
||||||
if ($drv === 'pgsql') {
|
|
||||||
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
|
|
||||||
'ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v';
|
|
||||||
} elseif ($drv === 'sqlite') {
|
|
||||||
$sql = 'INSERT OR REPLACE INTO ny_settings(k, v) VALUES(?, ?)';
|
|
||||||
} else {
|
|
||||||
$sql = 'INSERT INTO ny_settings(k, v) VALUES(?, ?) ' .
|
|
||||||
'ON DUPLICATE KEY UPDATE v = VALUES(v)';
|
|
||||||
}
|
|
||||||
$pdo->prepare($sql)->execute([$k, $v]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 名单:全量读 */
|
|
||||||
public static function rosterAll(): array
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if ($pdo === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
self::ensureRosterLogin();
|
|
||||||
$stmt = $pdo->query('SELECT sid, name, cls, grp, login FROM ny_roster ORDER BY id');
|
|
||||||
$rows = [];
|
|
||||||
foreach ($stmt->fetchAll() as $r) {
|
|
||||||
$rows[] = [
|
|
||||||
'sid' => (string)$r['sid'],
|
|
||||||
'name' => (string)$r['name'],
|
|
||||||
'cls' => (string)$r['cls'],
|
|
||||||
'grp' => (string)$r['grp'],
|
|
||||||
'login' => (string)($r['login'] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 名单:清空后全量写 */
|
|
||||||
public static function rosterReplace(array $students): void
|
|
||||||
{
|
|
||||||
$pdo = self::pdo();
|
|
||||||
if ($pdo === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self::ensureRosterLogin();
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
$pdo->exec('DELETE FROM ny_roster');
|
$p = self::prefix();
|
||||||
$st = $pdo->prepare('INSERT INTO ny_roster(sid, name, cls, grp, login) VALUES(?, ?, ?, ?, ?)');
|
$pdo->beginTransaction();
|
||||||
|
$pdo->exec("DELETE FROM {$p}roster");
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$i = 0;
|
||||||
foreach ($students as $s) {
|
foreach ($students as $s) {
|
||||||
|
if (!is_array($s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$st->execute([
|
$st->execute([
|
||||||
(string)($s['sid'] ?? ''),
|
(string)($s['sid'] ?? ''),
|
||||||
(string)($s['name'] ?? ''),
|
(string)($s['name'] ?? ''),
|
||||||
(string)($s['cls'] ?? ''),
|
(string)($s['cls'] ?? ''),
|
||||||
(string)($s['grp'] ?? ''),
|
(string)($s['grp'] ?? ''),
|
||||||
(string)($s['login'] ?? ''),
|
(string)($s['login'] ?? ''),
|
||||||
|
$i++,
|
||||||
|
time(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
return true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
throw $e;
|
}
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 名单:行数 */
|
|
||||||
public static function rosterCount(): int
|
public static function rosterCount(): int
|
||||||
{
|
{
|
||||||
$pdo = self::pdo();
|
$pdo = self::pdo();
|
||||||
if ($pdo === null) {
|
if (!$pdo) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return (int)$pdo->query('SELECT COUNT(*) FROM ny_roster')->fetchColumn();
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||||
|
|
||||||
|
|
||||||
|
/** @return array 单卷配置(缺省返回空结构) */
|
||||||
|
public static function projectConfigGet(string $projectId): array
|
||||||
|
{
|
||||||
|
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||||
|
if ($projectId === '') {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
|
||||||
|
$st->execute([$projectId]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
if ($v === false) {
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
$j = json_decode((string)$v, true);
|
||||||
|
return is_array($j) ? array_merge($def, $j) : $def;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
return $def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
|
||||||
|
public static function projectConfigSet(string $projectId, array $cfg): void
|
||||||
|
{
|
||||||
|
if ($projectId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo = self::pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$p = self::prefix();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
|
||||||
|
);
|
||||||
|
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
self::$err = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,49 @@ class Roster
|
|||||||
return class_exists('Db') && Db::pdo() !== null;
|
return class_exists('Db') && Db::pdo() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
|
||||||
|
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
|
||||||
|
*/
|
||||||
|
private static function migrateFileToDbOnce(): void
|
||||||
|
{
|
||||||
|
if (!self::useDb()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Db::rosterCount() > 0) {
|
||||||
|
return; // DB 已有数据,不覆盖
|
||||||
|
}
|
||||||
|
$d = ny_read_json(self::file(), null);
|
||||||
|
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$clean = [];
|
||||||
|
foreach ($d['students'] as $s) {
|
||||||
|
if (!is_array($s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$clean[] = [
|
||||||
|
'sid' => trim((string)($s['sid'] ?? '')),
|
||||||
|
'name' => trim((string)($s['name'] ?? '')),
|
||||||
|
'cls' => trim((string)($s['cls'] ?? '')),
|
||||||
|
'grp' => trim((string)($s['grp'] ?? '')),
|
||||||
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($clean !== []) {
|
||||||
|
Db::rosterReplace($clean);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 迁移失败不影响主流程:仍可退回文件读取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
|
self::migrateFileToDbOnce();
|
||||||
return Db::rosterAll();
|
return Db::rosterAll();
|
||||||
}
|
}
|
||||||
$d = ny_read_json(self::file(), null);
|
$d = ny_read_json(self::file(), null);
|
||||||
@@ -71,9 +110,23 @@ class Roster
|
|||||||
'login' => trim((string)($s['login'] ?? '')),
|
'login' => trim((string)($s['login'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 按学号去重(同 sid 取最后一条):roster 表 sid 为主键,重复 sid 会触发约束冲突导致整批回滚。
|
||||||
|
// 去重避免「保存成功但名单实际没变」的静默丢失。
|
||||||
|
$bySid = [];
|
||||||
|
$noSid = [];
|
||||||
|
foreach ($clean as $s) {
|
||||||
|
if ((string)($s['sid'] ?? '') !== '') {
|
||||||
|
$bySid[$s['sid']] = $s;
|
||||||
|
} else {
|
||||||
|
$noSid[] = $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$clean = array_values($bySid);
|
||||||
|
foreach ($noSid as $s) {
|
||||||
|
$clean[] = $s;
|
||||||
|
}
|
||||||
if (self::useDb()) {
|
if (self::useDb()) {
|
||||||
Db::rosterReplace($clean);
|
return Db::rosterReplace($clean);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return ny_write_json(self::file(), [
|
return ny_write_json(self::file(), [
|
||||||
'students' => $clean,
|
'students' => $clean,
|
||||||
@@ -99,35 +152,34 @@ class Roster
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
* 名单指纹:名单内容一变就变,用于数据缓存 key。
|
||||||
|
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
|
||||||
*/
|
*/
|
||||||
public static function ensureSeeded(): bool
|
public static function fingerprint(): string
|
||||||
{
|
{
|
||||||
if (self::count() > 0) {
|
if (self::useDb()) {
|
||||||
return false;
|
// 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
|
||||||
|
$n = self::count();
|
||||||
|
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
|
||||||
|
return 'db:' . $n . ':' . $hash;
|
||||||
}
|
}
|
||||||
$candidates = [
|
$f = self::file();
|
||||||
dirname(NY_ROOT) . '/导入模板.xlsx',
|
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
||||||
NY_ROOT . '/导入模板.xlsx',
|
$sz = is_file($f) ? (string)filesize($f) : '0';
|
||||||
NY_DATA . '/导入模板.xlsx',
|
$d = ny_read_json($f, null);
|
||||||
];
|
$hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
|
||||||
foreach ($candidates as $f) {
|
return $mt . ':' . $sz . ':' . $hash;
|
||||||
if (is_file($f)) {
|
|
||||||
try {
|
|
||||||
$res = self::importFile($f, 'xlsx');
|
|
||||||
return $res['count'] > 0;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析上传文件 -> 学生数组
|
* 解析上传文件 -> 学生数组
|
||||||
* @return array{count:int,students:array}
|
* @return array{count:int,students:array}
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 解析上传文件 -> 学生数组
|
||||||
|
* @return array{count:int,skipped:int,duplicates:int,headers:array,students:array}
|
||||||
|
*/
|
||||||
public static function importFile(string $file, string $ext = ''): array
|
public static function importFile(string $file, string $ext = ''): array
|
||||||
{
|
{
|
||||||
if ($ext === '') {
|
if ($ext === '') {
|
||||||
@@ -148,9 +200,17 @@ class Roster
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($rows === []) {
|
if ($rows === []) {
|
||||||
throw new RuntimeException('文件为空或无法解析');
|
throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)');
|
||||||
}
|
}
|
||||||
return ['count' => count($rows), 'students' => self::rowsToStudents($rows)];
|
$meta = [];
|
||||||
|
$students = self::rowsToStudents($rows, $meta);
|
||||||
|
return [
|
||||||
|
'count' => count($students),
|
||||||
|
'skipped' => $meta['skipped'],
|
||||||
|
'duplicates' => $meta['duplicates'],
|
||||||
|
'headers' => $meta['headers'],
|
||||||
|
'students' => $students,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function parseCsv(string $file): array
|
public static function parseCsv(string $file): array
|
||||||
@@ -195,10 +255,13 @@ class Roster
|
|||||||
/**
|
/**
|
||||||
* 识别 学号/姓名/班级/组别/登陆账号 列
|
* 识别 学号/姓名/班级/组别/登陆账号 列
|
||||||
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
|
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
|
||||||
|
* @param array $rows
|
||||||
|
* @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array]
|
||||||
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
|
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
|
||||||
*/
|
*/
|
||||||
public static function rowsToStudents(array $rows): array
|
public static function rowsToStudents(array $rows, array &$meta = []): array
|
||||||
{
|
{
|
||||||
|
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
|
||||||
$sidCol = -1;
|
$sidCol = -1;
|
||||||
$nameCol = -1;
|
$nameCol = -1;
|
||||||
$clsCol = -1;
|
$clsCol = -1;
|
||||||
@@ -206,11 +269,11 @@ class Roster
|
|||||||
$loginCol = -1;
|
$loginCol = -1;
|
||||||
$headerIdx = -1;
|
$headerIdx = -1;
|
||||||
|
|
||||||
// 找表头
|
// 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
|
||||||
foreach ($rows as $i => $row) {
|
foreach ($rows as $i => $row) {
|
||||||
$joined = implode('|', array_map('strval', $row));
|
$joined = implode('|', array_map('strval', $row));
|
||||||
if (preg_match('/学\s*号|编\s*号|序\s*号|no\.?|id/i', $joined)
|
if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
|
||||||
&& preg_match('/姓\s*名|名\s*字|学生|成员/i', $joined)) {
|
&& preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
|
||||||
$headerIdx = $i;
|
$headerIdx = $i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -220,22 +283,25 @@ class Roster
|
|||||||
$start = $headerIdx + 1;
|
$start = $headerIdx + 1;
|
||||||
foreach ($rows[$headerIdx] as $j => $cell) {
|
foreach ($rows[$headerIdx] as $j => $cell) {
|
||||||
$c = (string)$cell;
|
$c = (string)$cell;
|
||||||
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
|
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
|
||||||
$sidCol = $j;
|
$sidCol = $j;
|
||||||
}
|
}
|
||||||
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学生姓名/i', $c)) {
|
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) {
|
||||||
$nameCol = $j;
|
$nameCol = $j;
|
||||||
}
|
}
|
||||||
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
|
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
|
||||||
$clsCol = $j;
|
$clsCol = $j;
|
||||||
}
|
}
|
||||||
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|^\s*组\s*$|group/i', $c)) {
|
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) {
|
||||||
$grpCol = $j;
|
$grpCol = $j;
|
||||||
}
|
}
|
||||||
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
|
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
|
||||||
$loginCol = $j;
|
$loginCol = $j;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$meta['headers'] = [
|
||||||
|
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
if ($sidCol < 0) {
|
if ($sidCol < 0) {
|
||||||
$sidCol = 0;
|
$sidCol = 0;
|
||||||
@@ -245,20 +311,27 @@ class Roster
|
|||||||
}
|
}
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
for ($i = $start; $i < count($rows); $i++) {
|
for ($i = $start; $i < count($rows); $i++) {
|
||||||
$row = $rows[$i];
|
$row = $rows[$i];
|
||||||
$sid = trim((string)($row[$sidCol] ?? ''));
|
$sid = trim((string)($row[$sidCol] ?? ''));
|
||||||
$name = trim((string)($row[$nameCol] ?? ''));
|
$name = trim((string)($row[$nameCol] ?? ''));
|
||||||
if ($sid === '' && $name === '') {
|
if ($sid === '' && $name === '') {
|
||||||
|
$meta['skipped']++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 学号去掉 .0 尾巴(Excel 数字读取)
|
// 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
|
||||||
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
|
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
|
||||||
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
|
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
|
||||||
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
|
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
|
||||||
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
|
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
|
||||||
// 登陆账号同样去掉 Excel 数字尾巴(手机号被读成 13912687810.0)
|
|
||||||
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
|
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
|
||||||
|
// 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入)
|
||||||
|
if ($sid !== '' && isset($seen[$sid])) {
|
||||||
|
$meta['duplicates']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$sid] = true;
|
||||||
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
|
|||||||
@@ -187,7 +187,10 @@ class SurveyKingClient
|
|||||||
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
|
$msg = (string)($decoded['msg'] ?? $decoded['message'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawBody, 'code' => $code, 'msg' => $msg];
|
// 仅保留前若干字符用于错误诊断;完整正文不再随返回值常驻内存,
|
||||||
|
// 避免大答卷响应在 json_decode 之外再额外占用一倍内存。
|
||||||
|
$rawDiag = mb_strcut($rawBody, 0, 2000);
|
||||||
|
return ['status' => $status, 'headers' => $h, 'data' => $data, 'raw' => $rawDiag, 'code' => $code, 'msg' => $msg];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,7 +451,7 @@ class SurveyKingClient
|
|||||||
/** 问卷 / 考试列表 */
|
/** 问卷 / 考试列表 */
|
||||||
public function listProjects(): array
|
public function listProjects(): array
|
||||||
{
|
{
|
||||||
$size = max(20, (int)($this->cfg['page_size'] ?? 200));
|
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
|
||||||
$all = [];
|
$all = [];
|
||||||
for ($page = 1; $page <= 50; $page++) {
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
|
$r = $this->request('/project/list', ['current' => $page, 'pageSize' => $size]);
|
||||||
@@ -480,10 +483,183 @@ class SurveyKingClient
|
|||||||
return is_array($r['data']) ? $r['data'] : [];
|
return is_array($r['data']) ? $r['data'] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「应考名单」来源(待考 / 缺考名单由此驱动)。
|
||||||
|
*
|
||||||
|
* 卷王真实模型(社区版 v1.13 与开源版一致):
|
||||||
|
* 限制谁可以答卷的是项目「参与者(受访者)」—— t_project_partner 中
|
||||||
|
* type = RESPONDENT_SYS_USER(2) / RESPONDENT_IMP_USER(3) 的记录,
|
||||||
|
* 经 /api/project/partner/list 返回,每条带 status(0未访问 / 1已访问未答 / 2已答题)。
|
||||||
|
* 这才是「考试白名单 / 通讯录名单」的真实载体。
|
||||||
|
*
|
||||||
|
* 旧代码误把 surveySetting.whiteLists(按「题目选项」白名单,限制某题可选项)当成应考名单,
|
||||||
|
* 字段形状根本不对,导致线上从未触发。这里以真实模型为准,whiteLists 仅作极老版本的兜底。
|
||||||
|
*
|
||||||
|
* @param string $projectId 问卷 ID
|
||||||
|
* @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
|
||||||
|
* @return array<int,string> 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
|
||||||
|
*/
|
||||||
|
public function getWhitelist(string $projectId, ?array $survey = null): array
|
||||||
|
{
|
||||||
|
// 1) 真实模型:项目「受访者」参与者 = 应考名单
|
||||||
|
$partners = [];
|
||||||
|
try {
|
||||||
|
$partners = $this->getRespondentPartners($projectId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->log[] = 'getRespondentPartners 失败(回退白名单兜底):' . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($partners !== []) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($partners as $p) {
|
||||||
|
if (!is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$type = (int)($p['type'] ?? 0);
|
||||||
|
if ($type !== 2 && $type !== 3) {
|
||||||
|
continue; // 仅取受访者(系统用户 / 导入用户),排除 OWNER / COLLABORATOR
|
||||||
|
}
|
||||||
|
$name = trim((string)($p['userName'] ?? ''));
|
||||||
|
$userId = trim((string)($p['userId'] ?? ''));
|
||||||
|
$user = $p['user'] ?? null;
|
||||||
|
$login = '';
|
||||||
|
if (is_array($user)) {
|
||||||
|
if ($name === '') {
|
||||||
|
$name = trim((string)($user['name'] ?? ''));
|
||||||
|
}
|
||||||
|
$login = trim((string)($user['login'] ?? $user['account'] ?? $user['username'] ?? ''));
|
||||||
|
if ($userId === '') {
|
||||||
|
$userId = trim((string)($user['id'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 与本地名单 / 答卷姓名匹配的标识:姓名 > 登陆账号 > userId
|
||||||
|
$key = $name !== '' ? $name : ($login !== '' ? $login : $userId);
|
||||||
|
if ($key !== '') {
|
||||||
|
$out[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重(大小写不敏感),保留首次出现顺序
|
||||||
|
$seen = [];
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $item) {
|
||||||
|
$k = strtolower($item);
|
||||||
|
if (!isset($seen[$k])) {
|
||||||
|
$seen[$k] = true;
|
||||||
|
$res[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($res !== []) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 兜底:极老 / 定制版本可能把应考名单放在 surveySetting.whiteLists(实为题目选项白名单)
|
||||||
|
if (is_array($survey)) {
|
||||||
|
$setting = null;
|
||||||
|
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
|
||||||
|
$setting = $survey['surveySetting'];
|
||||||
|
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
|
||||||
|
$setting = $survey['survey']['surveySetting'];
|
||||||
|
} else {
|
||||||
|
// 顶层直接是白名单结构
|
||||||
|
foreach (['whiteLists', 'whiteList', 'whitelist'] as $wk) {
|
||||||
|
if (isset($survey[$wk]) && (is_array($survey[$wk]) || is_string($survey[$wk]))) {
|
||||||
|
$setting = $survey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_array($setting)) {
|
||||||
|
return self::parseWhitelist($setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取项目的「受访者」参与者列表(应考名单真实来源)。
|
||||||
|
* GET /api/project/partner/list?projectId=...&types=2,3(RESPONDENT_SYS_USER / RESPONDENT_IMP_USER),自动翻页。
|
||||||
|
* @return array<int,array{id:string,type:int,userName:string,userId:string,status:int,user?:array}>
|
||||||
|
*/
|
||||||
|
public function getRespondentPartners(string $projectId): array
|
||||||
|
{
|
||||||
|
$size = 200;
|
||||||
|
$all = [];
|
||||||
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
|
$r = $this->request('/project/partner/list', [
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'types' => '2,3',
|
||||||
|
'current' => $page,
|
||||||
|
'pageSize' => $size,
|
||||||
|
]);
|
||||||
|
$data = $r['data'];
|
||||||
|
$list = is_array($data) ? ($data['list'] ?? $data) : [];
|
||||||
|
if (!is_array($list) || $list === []) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($list as $p) {
|
||||||
|
if (is_array($p)) {
|
||||||
|
$all[] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
||||||
|
if ($total > 0 && count($all) >= $total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (count($list) < $size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 surveySetting 中解析白名单成员(去重、保留首次出现顺序)。
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function parseWhitelist(array $setting): array
|
||||||
|
{
|
||||||
|
$raw = $setting['whiteLists'] ?? $setting['whiteList'] ?? $setting['whitelist'] ?? null;
|
||||||
|
if ($raw === null || $raw === [] || $raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$walk = static function ($node) use (&$out, &$walk): void {
|
||||||
|
if (is_array($node)) {
|
||||||
|
// 列表或对象都逐值递归(兼容 { 题ID: [值] })
|
||||||
|
foreach ($node as $v) {
|
||||||
|
$walk($v);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_string($node) && trim($node) !== '') {
|
||||||
|
$out[] = trim($node);
|
||||||
|
} elseif (is_scalar($node) && $node !== '' && $node !== null) {
|
||||||
|
$out[] = (string)$node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$walk($raw);
|
||||||
|
|
||||||
|
// 去重(大小写不敏感),保留首次出现顺序
|
||||||
|
$seen = [];
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $item) {
|
||||||
|
$key = strtolower($item);
|
||||||
|
if (!isset($seen[$key])) {
|
||||||
|
$seen[$key] = true;
|
||||||
|
$res[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
/** 答卷列表(自动翻页取全量) */
|
/** 答卷列表(自动翻页取全量) */
|
||||||
public function listAnswers(string $projectId): array
|
public function listAnswers(string $projectId): array
|
||||||
{
|
{
|
||||||
$size = max(20, (int)($this->cfg['page_size'] ?? 200));
|
// 单页上限夹在 20~200:无论配置 page_size 多大,单次 json_decode 的体量都被限制住,
|
||||||
|
// 避免一份超大响应直接撑爆 128M。100 页 × 200 = 最多 2 万份,足以覆盖学校级考试。
|
||||||
|
$size = min(200, max(20, (int)($this->cfg['page_size'] ?? 100)));
|
||||||
$all = [];
|
$all = [];
|
||||||
for ($page = 1; $page <= 100; $page++) {
|
for ($page = 1; $page <= 100; $page++) {
|
||||||
$r = $this->request('/answer/list', [
|
$r = $this->request('/answer/list', [
|
||||||
@@ -502,10 +678,14 @@ class SurveyKingClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
$total = is_array($data) ? (int)($data['total'] ?? 0) : 0;
|
||||||
|
// 及时释放本页引用,降低峰值内存占用
|
||||||
|
$r = null;
|
||||||
|
$data = null;
|
||||||
|
$list = null;
|
||||||
if ($total > 0 && count($all) >= $total) {
|
if ($total > 0 && count($all) >= $total) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (count($list) < $size) {
|
if (count($all) < $size) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
|
||||||
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
|
||||||
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
|
||||||
* - 第一行固定为表头(加粗 + 居中)
|
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
|
||||||
|
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
|
||||||
|
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
|
||||||
|
* 用于"每场最高分标红、合格次数达标标绿"
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
@@ -15,100 +18,309 @@ final class XlsxWriter
|
|||||||
private array $strIndex = [];
|
private array $strIndex = [];
|
||||||
/** @var array<int,string> 索引 => 共享字符串 */
|
/** @var array<int,string> 索引 => 共享字符串 */
|
||||||
private array $strings = [];
|
private array $strings = [];
|
||||||
/** @var array<int,array<int,string>> 行数据 */
|
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||||
private array $rows = [];
|
private array $strCount = [];
|
||||||
/** @var array<int,float> 列宽(1 基) */
|
/** @var array<int,array{name:string,rows:array,widths:array,merges:array}> 工作表(按输出顺序) */
|
||||||
private array $widths = [];
|
private array $sheets = [];
|
||||||
|
|
||||||
// 样式索引(见 stylesXml)
|
// 样式索引(见 stylesXml)
|
||||||
private const S_DEFAULT = 0; // 常规
|
private const S_DEFAULT = 0; // 常规(无边框)
|
||||||
private const S_HEADER = 1; // 表头:加粗 + 居中
|
private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框
|
||||||
private const S_TEXT = 2; // 文本格式(numFmtId=49)
|
private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框
|
||||||
|
private const S_RED = 3; // 数据 + 红字(最高分标记)
|
||||||
|
private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记)
|
||||||
|
private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 xlsx 二进制内容
|
* 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'),
|
||||||
|
* 具体配色编号由本类统一维护,避免外部硬编码样式下标。
|
||||||
|
*/
|
||||||
|
private const MARK_STYLES = [
|
||||||
|
'red' => self::S_RED,
|
||||||
|
'green' => self::S_GREEN,
|
||||||
|
'header_green' => self::S_HEADER_GREEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单表 xlsx 二进制内容(兼容旧调用)
|
||||||
*
|
*
|
||||||
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
|
* @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串
|
||||||
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
|
* @param array $opts ['widths' => [列宽,...], 'title' => 工作表名]
|
||||||
* @return string xlsx 文件二进制
|
* @return string xlsx 文件二进制
|
||||||
*/
|
*/
|
||||||
public static function render(array $rows, array $opts = []): string
|
public static function render(array $rows, array $opts = []): string
|
||||||
|
{
|
||||||
|
return self::renderMulti([[
|
||||||
|
'name' => (string)($opts['title'] ?? 'Sheet1'),
|
||||||
|
'rows' => $rows,
|
||||||
|
'widths' => array_values((array)($opts['widths'] ?? [])),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成多工作表 xlsx
|
||||||
|
*
|
||||||
|
* 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」,
|
||||||
|
* 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容
|
||||||
|
* 当成公式执行(天然规避 CSV 公式注入问题)。
|
||||||
|
*
|
||||||
|
* @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int,
|
||||||
|
* 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]]
|
||||||
|
* merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并
|
||||||
|
* header_rows 表头行数(默认 1;两行表头的交叉表用 2)
|
||||||
|
* cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略)
|
||||||
|
* @return string xlsx 文件二进制
|
||||||
|
*/
|
||||||
|
public static function renderMulti(array $sheets): string
|
||||||
{
|
{
|
||||||
$w = new self();
|
$w = new self();
|
||||||
$w->rows = array_values($rows);
|
foreach ($sheets as $sh) {
|
||||||
$w->widths = array_values((array)($opts['widths'] ?? []));
|
if (!is_array($sh)) {
|
||||||
return $w->build((string)($opts['title'] ?? 'Sheet1'));
|
continue;
|
||||||
|
}
|
||||||
|
$rows = array_values((array)($sh['rows'] ?? []));
|
||||||
|
if ($rows === []) {
|
||||||
|
continue; // 空表不输出,避免 Excel 提示修复
|
||||||
|
}
|
||||||
|
$w->sheets[] = [
|
||||||
|
'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets),
|
||||||
|
'rows' => $rows,
|
||||||
|
'widths' => array_values((array)($sh['widths'] ?? [])),
|
||||||
|
'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)),
|
||||||
|
'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)),
|
||||||
|
'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($w->sheets === []) {
|
||||||
|
// 保底:至少输出一张空表
|
||||||
|
$w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []];
|
||||||
|
}
|
||||||
|
return $w->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。
|
||||||
|
*
|
||||||
|
* @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受)
|
||||||
|
* @return array<string,int>
|
||||||
|
*/
|
||||||
|
private static function normalizeCellStyles(array $raw): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $k => $mark) {
|
||||||
|
if (is_array($k) && count($k) >= 2) {
|
||||||
|
$key = ((int)$k[0]) . ',' . ((int)$k[1]);
|
||||||
|
} else {
|
||||||
|
$key = (string)$k;
|
||||||
|
if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = ((int)$m[1]) . ',' . ((int)$m[2]);
|
||||||
|
}
|
||||||
|
$idx = self::MARK_STYLES[(string)$mark] ?? null;
|
||||||
|
if ($idx === null) {
|
||||||
|
continue; // 未知标记:不写样式,不报错
|
||||||
|
}
|
||||||
|
[$r, $c] = array_map('intval', explode(',', $key));
|
||||||
|
if ($r < 1 || $c < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = $idx;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二维表最大列数(至少 1) */
|
||||||
|
private static function maxCols(array $rows): int
|
||||||
|
{
|
||||||
|
$max = 1;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$max = max($max, count((array)$r));
|
||||||
|
}
|
||||||
|
return $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。
|
||||||
|
*
|
||||||
|
* @param array $raw [[r1,c1,r2,c2], ...] 1-based
|
||||||
|
* @return array<int,array{0:int,1:int,2:int,3:int}>
|
||||||
|
*/
|
||||||
|
private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $m) {
|
||||||
|
if (!is_array($m) || count($m) < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3];
|
||||||
|
if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; }
|
||||||
|
if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; }
|
||||||
|
if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) {
|
||||||
|
continue; // 越界丢弃
|
||||||
|
}
|
||||||
|
if ($r1 === $r2 && $c1 === $c2) {
|
||||||
|
continue; // 单格无需合并
|
||||||
|
}
|
||||||
|
$out[] = [$r1, $c1, $r2, $c2];
|
||||||
|
}
|
||||||
|
usort($out, static function (array $a, array $b): int {
|
||||||
|
return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]];
|
||||||
|
});
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 内部:构建 ---------------- */
|
/* ---------------- 内部:构建 ---------------- */
|
||||||
|
|
||||||
private function build(string $title): string
|
private function build(): string
|
||||||
{
|
{
|
||||||
|
$files = [
|
||||||
|
'[Content_Types].xml' => '', // 占位,下面按表数量生成
|
||||||
|
];
|
||||||
|
$sheetXmls = [];
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($this->sheets as $i => $sh) {
|
||||||
|
$n = $i + 1;
|
||||||
|
|
||||||
$maxCol = 1;
|
$maxCol = 1;
|
||||||
foreach ($this->rows as $r) {
|
foreach ($sh['rows'] as $r) {
|
||||||
$maxCol = max($maxCol, count((array)$r));
|
$maxCol = max($maxCol, count((array)$r));
|
||||||
}
|
}
|
||||||
$maxRow = max(1, count($this->rows));
|
$maxRow = max(1, count($sh['rows']));
|
||||||
|
|
||||||
// 先登记所有字符串,生成 sharedStrings 索引
|
// 登记共享字符串并生成单元格索引
|
||||||
$cells = [];
|
$cells = [];
|
||||||
foreach ($this->rows as $ri => $row) {
|
foreach (array_values($sh['rows']) as $ri => $row) {
|
||||||
$r = (int)$ri + 1;
|
$r = (int)$ri + 1;
|
||||||
foreach (array_values((array)$row) as $ci => $val) {
|
foreach (array_values((array)$row) as $ci => $val) {
|
||||||
$c = (int)$ci + 1;
|
$c = (int)$ci + 1;
|
||||||
$s = $this->intern((string)($val ?? ''));
|
$cells[$r][$c] = $this->intern((string)($val ?? ''));
|
||||||
$cells[$r][$c] = $s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml(
|
||||||
|
$cells,
|
||||||
|
$maxRow,
|
||||||
|
$maxCol,
|
||||||
|
$sh['widths'],
|
||||||
|
$sh['merges'] ?? [],
|
||||||
|
(int)($sh['header_rows'] ?? 1),
|
||||||
|
(array)($sh['cell_styles'] ?? [])
|
||||||
|
);
|
||||||
|
$names[] = $sh['name'];
|
||||||
|
}
|
||||||
|
|
||||||
$sheet = $this->sheetXml($cells, $maxRow, $maxCol);
|
$count = count($this->sheets);
|
||||||
$files = [
|
$files = [
|
||||||
'[Content_Types].xml' => $this->contentTypesXml(),
|
'[Content_Types].xml' => $this->contentTypesXml($count),
|
||||||
'_rels/.rels' => $this->rootRelsXml(),
|
'_rels/.rels' => $this->rootRelsXml(),
|
||||||
'docProps/app.xml' => $this->appXml(),
|
'docProps/app.xml' => $this->appXml(),
|
||||||
'docProps/core.xml' => $this->coreXml($title),
|
'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'),
|
||||||
'xl/workbook.xml' => $this->workbookXml($title),
|
'xl/workbook.xml' => $this->workbookXml($names),
|
||||||
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(),
|
'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count),
|
||||||
'xl/styles.xml' => $this->stylesXml(),
|
'xl/styles.xml' => $this->stylesXml(),
|
||||||
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
|
'xl/sharedStrings.xml' => $this->sharedStringsXml(),
|
||||||
'xl/worksheets/sheet1.xml' => $sheet,
|
] + $sheetXmls;
|
||||||
];
|
|
||||||
|
|
||||||
return $this->zip($files);
|
return $this->zip($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空
|
||||||
|
*/
|
||||||
|
private static function safeSheetName(string $name, int $seq, array $existing): string
|
||||||
|
{
|
||||||
|
$bad = ['\\', '/', '?', '*', '[', ']', ':'];
|
||||||
|
$n = trim(str_replace($bad, '_', $name));
|
||||||
|
if ($n === '') {
|
||||||
|
$n = 'Sheet' . $seq;
|
||||||
|
}
|
||||||
|
if (function_exists('mb_substr')) {
|
||||||
|
$n = mb_substr($n, 0, 31, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$n = substr($n, 0, 31);
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
$base = $n;
|
||||||
|
$i = 2;
|
||||||
|
$taken = [];
|
||||||
|
foreach ($existing as $e) {
|
||||||
|
$taken[strtolower($e['name'])] = true;
|
||||||
|
}
|
||||||
|
while (isset($taken[strtolower($n)])) {
|
||||||
|
$suffix = '(' . $i . ')';
|
||||||
|
$n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
private function intern(string $s): int
|
private function intern(string $s): int
|
||||||
{
|
{
|
||||||
if (!array_key_exists($s, $this->strIndex)) {
|
if (!array_key_exists($s, $this->strIndex)) {
|
||||||
$i = count($this->strings);
|
$i = count($this->strings);
|
||||||
$this->strIndex[$s] = $i;
|
$this->strIndex[$s] = $i;
|
||||||
$this->strings[$i] = $s;
|
$this->strings[$i] = $s;
|
||||||
|
$this->strCount[$i] = 0;
|
||||||
}
|
}
|
||||||
return $this->strIndex[$s];
|
$i = $this->strIndex[$s];
|
||||||
|
$this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1;
|
||||||
|
return $i;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sheetXml(array $cells, int $maxRow, int $maxCol): string
|
private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string
|
||||||
{
|
{
|
||||||
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
$dim = 'A1:' . self::colName($maxCol) . $maxRow;
|
||||||
|
|
||||||
$cols = '';
|
$cols = '';
|
||||||
for ($c = 1; $c <= $maxCol; $c++) {
|
for ($c = 1; $c <= $maxCol; $c++) {
|
||||||
$w = $this->widths[$c - 1] ?? 16;
|
$w = $widths[$c - 1] ?? 16;
|
||||||
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
$cols .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"),
|
||||||
|
// 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。
|
||||||
|
$swallowed = [];
|
||||||
|
foreach ($merges as $m) {
|
||||||
|
for ($r = $m[0]; $r <= $m[2]; $r++) {
|
||||||
|
for ($c = $m[1]; $c <= $m[3]; $c++) {
|
||||||
|
if ($r === $m[0] && $c === $m[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$swallowed[$r][$c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$data = '';
|
$data = '';
|
||||||
for ($r = 1; $r <= $maxRow; $r++) {
|
for ($r = 1; $r <= $maxRow; $r++) {
|
||||||
|
$baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT;
|
||||||
$line = '';
|
$line = '';
|
||||||
$has = false;
|
$has = false;
|
||||||
for ($c = 1; $c <= $maxCol; $c++) {
|
for ($c = 1; $c <= $maxCol; $c++) {
|
||||||
if (!isset($cells[$r][$c])) {
|
$sw = isset($swallowed[$r][$c]);
|
||||||
|
if (!isset($cells[$r][$c]) && !$sw) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$has = true;
|
$has = true;
|
||||||
$s = $r === 1 ? self::S_HEADER : self::S_TEXT;
|
$ref = self::colName($c) . $r;
|
||||||
$line .= '<c r="' . self::colName($c) . $r . '" s="' . $s . '" t="s"><v>' .
|
// 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式,
|
||||||
$cells[$r][$c] . '</v></c>';
|
// 否则"合并成一格但只有左边半截有底色"会很难看。
|
||||||
|
$mark = $cellStyles[$r . ',' . $c] ?? null;
|
||||||
|
if ($mark === null && $sw) {
|
||||||
|
foreach ($merges as $m) {
|
||||||
|
if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) {
|
||||||
|
$mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$style = ($mark !== null) ? (int)$mark : $baseStyle;
|
||||||
|
if ($sw) {
|
||||||
|
$line .= '<c r="' . $ref . '" s="' . $style . '"/>';
|
||||||
|
} else {
|
||||||
|
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!$has) {
|
if (!$has) {
|
||||||
continue;
|
continue;
|
||||||
@@ -116,6 +328,15 @@ final class XlsxWriter
|
|||||||
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mergeXml = '';
|
||||||
|
if ($merges !== []) {
|
||||||
|
$mergeXml = '<mergeCells count="' . count($merges) . '">';
|
||||||
|
foreach ($merges as $m) {
|
||||||
|
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
|
||||||
|
}
|
||||||
|
$mergeXml .= '</mergeCells>';
|
||||||
|
}
|
||||||
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||||
'<dimension ref="' . $dim . '"/>' .
|
'<dimension ref="' . $dim . '"/>' .
|
||||||
@@ -123,17 +344,23 @@ final class XlsxWriter
|
|||||||
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
'<sheetFormatPr defaultRowHeight="13.5"/>' .
|
||||||
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
|
||||||
'<sheetData>' . $data . '</sheetData>' .
|
'<sheetData>' . $data . '</sheetData>' .
|
||||||
|
$mergeXml .
|
||||||
'</worksheet>';
|
'</worksheet>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function contentTypesXml(): string
|
private function contentTypesXml(int $sheetCount): string
|
||||||
{
|
{
|
||||||
|
$overrides = '';
|
||||||
|
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
|
||||||
|
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
|
||||||
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
|
||||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
|
||||||
'<Default Extension="xml" ContentType="application/xml"/>' .
|
'<Default Extension="xml" ContentType="application/xml"/>' .
|
||||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
|
||||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' .
|
$overrides .
|
||||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
|
||||||
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
|
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
|
||||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
|
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
|
||||||
@@ -151,22 +378,36 @@ final class XlsxWriter
|
|||||||
'</Relationships>';
|
'</Relationships>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workbookXml(string $title): string
|
/** @param array<int,string> $names 工作表名(已安全化) */
|
||||||
|
private function workbookXml(array $names): string
|
||||||
{
|
{
|
||||||
|
$sheets = '';
|
||||||
|
foreach (array_values($names) as $i => $nm) {
|
||||||
|
$n = (int)$i + 1;
|
||||||
|
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
|
||||||
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
|
||||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
|
||||||
'<sheets><sheet name="' . self::xml($title) . '" sheetId="1" r:id="rId1"/></sheets>' .
|
'<sheets>' . $sheets . '</sheets>' .
|
||||||
'</workbook>';
|
'</workbook>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workbookRelsXml(): string
|
private function workbookRelsXml(int $sheetCount): string
|
||||||
{
|
{
|
||||||
|
$rels = '';
|
||||||
|
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
|
||||||
|
$rels .= '<Relationship Id="rId' . $i . '" ' .
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
|
||||||
|
'Target="worksheets/sheet' . $i . '.xml"/>';
|
||||||
|
}
|
||||||
|
$stylesId = max(1, $sheetCount) + 1;
|
||||||
|
$sstId = max(1, $sheetCount) + 2;
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
|
||||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' .
|
$rels .
|
||||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
|
'<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
|
||||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
|
'<Relationship Id="rId' . $sstId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
|
||||||
'</Relationships>';
|
'</Relationships>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +417,38 @@ final class XlsxWriter
|
|||||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
|
||||||
// numFmts: 49 = @(文本)
|
// numFmts: 49 = @(文本)
|
||||||
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
|
||||||
'<fonts count="2">' .
|
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
|
||||||
|
'<fonts count="3">' .
|
||||||
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||||
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
|
||||||
|
'<font><sz val="11"/><color rgb="FFD0021B"/><name val="宋体"/><charset val="134"/></font>' .
|
||||||
'</fonts>' .
|
'</fonts>' .
|
||||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>' .
|
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
|
||||||
'<fill><patternFill patternType="gray125"/></fill></fills>' .
|
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
|
||||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' .
|
'<fills count="4">' .
|
||||||
|
'<fill><patternFill patternType="none"/></fill>' .
|
||||||
|
'<fill><patternFill patternType="gray125"/></fill>' .
|
||||||
|
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||||
|
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
|
||||||
|
'</fills>' .
|
||||||
|
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
|
||||||
|
'<borders count="2">' .
|
||||||
|
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
|
||||||
|
'<border>' .
|
||||||
|
'<left style="thin"><color rgb="FF808080"/></left>' .
|
||||||
|
'<right style="thin"><color rgb="FF808080"/></right>' .
|
||||||
|
'<top style="thin"><color rgb="FF808080"/></top>' .
|
||||||
|
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
|
||||||
|
'<diagonal/></border>' .
|
||||||
|
'</borders>' .
|
||||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
|
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
|
||||||
'<cellXfs count="3">' .
|
'<cellXfs count="6">' .
|
||||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
|
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
|
||||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
|
||||||
'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment vertical="center"/></xf>' . // 2 文本
|
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
|
||||||
|
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
|
||||||
|
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
|
||||||
|
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
|
||||||
'</cellXfs>' .
|
'</cellXfs>' .
|
||||||
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
|
||||||
'</styleSheet>';
|
'</styleSheet>';
|
||||||
@@ -195,14 +456,15 @@ final class XlsxWriter
|
|||||||
|
|
||||||
private function sharedStringsXml(): string
|
private function sharedStringsXml(): string
|
||||||
{
|
{
|
||||||
$count = count($this->strings);
|
$unique = count($this->strings);
|
||||||
|
$total = array_sum($this->strCount);
|
||||||
$body = '';
|
$body = '';
|
||||||
foreach ($this->strings as $s) {
|
foreach ($this->strings as $s) {
|
||||||
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
||||||
}
|
}
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
|
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
|
||||||
'" uniqueCount="' . $count . '">' . $body . '</sst>';
|
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function coreXml(string $title): string
|
private function coreXml(string $title): string
|
||||||
|
|||||||
@@ -0,0 +1,853 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 多考试导出:成绩对比矩阵 + 「每次考试」块表的纯逻辑(不依赖卷王客户端,便于单测)
|
||||||
|
*
|
||||||
|
* 两种版式:
|
||||||
|
* 1) 成绩对比(ny_assemble_multi_exam_matrix):一行一个学生、一列一场考试,只取最新 / 最高分
|
||||||
|
* 2) 每次考试块表(ny_assemble_block_sheet):两行表头,一行一次考试,学号 / 姓名 / 班级 / 组别
|
||||||
|
* 纵向合并单元格,没考的考试合并成「未考」一格(见需求截图版式)
|
||||||
|
*
|
||||||
|
* 说明:本文件刻意只用 PHP 7.0+ 语法(不用箭头函数 / 数字分隔符),
|
||||||
|
* 便于在只有 PHP 7.x 的机器上直接跑回归测试。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个学生在某场考试里的"单元格成绩"。
|
||||||
|
*
|
||||||
|
* - highest:取真实最高分(跨全部尝试,含重考),即聚合行的 best_score
|
||||||
|
* - latest :取 attempts_list 中「最近一次非暂存提交」的成绩;
|
||||||
|
* attempts_list 全为暂存草稿时退回最后一份;列表缺失时退回代表行 score
|
||||||
|
*
|
||||||
|
* 与单考试"按最新成绩"导出口径完全一致(跳过 temp_save 草稿,避免导出的"最新成绩"是草稿分)。
|
||||||
|
*
|
||||||
|
* @return float|null 未提交 / 无成绩返回 null
|
||||||
|
*/
|
||||||
|
function ny_exam_cell_score(array $r, string $mode): ?float
|
||||||
|
{
|
||||||
|
if ($mode === 'highest') {
|
||||||
|
$v = $r['best_score'] ?? null;
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
// 最近一次非暂存提交
|
||||||
|
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||||
|
$last = null;
|
||||||
|
for ($i = count($list) - 1; $i >= 0; $i--) {
|
||||||
|
if (empty($list[$i]['temp_save'])) {
|
||||||
|
$last = $list[$i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($last === null && $list !== []) {
|
||||||
|
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
|
||||||
|
}
|
||||||
|
if ($last === null) {
|
||||||
|
$v = $r['score'] ?? null; // 无明细:退回代表行
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
$v = $last['score'] ?? null;
|
||||||
|
return ($v === null || $v === '') ? null : (float)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把多场考试已构建好的结果,组装成"成绩对比"二维矩阵。
|
||||||
|
*
|
||||||
|
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
|
||||||
|
* @param string $scoreMode 'latest' | 'highest'
|
||||||
|
* @return array{head:array,rows:array,exam_order:array,exam_names:array}
|
||||||
|
*/
|
||||||
|
function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
|
||||||
|
{
|
||||||
|
$examOrder = [];
|
||||||
|
$examNames = [];
|
||||||
|
$scoreByExam = []; // pid => [sid => float|null]
|
||||||
|
$nameBySid = []; // sid => 姓名(首个非空)
|
||||||
|
|
||||||
|
foreach ($exams as $pid => $ex) {
|
||||||
|
$examOrder[] = $pid;
|
||||||
|
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
|
||||||
|
$map = [];
|
||||||
|
foreach ((array)($ex['rows'] ?? []) as $r) {
|
||||||
|
$sid = trim((string)($r['sid'] ?? ''));
|
||||||
|
if ($sid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($nameBySid[$sid]) && trim((string)($r['name'] ?? '')) !== '') {
|
||||||
|
$nameBySid[$sid] = (string)$r['name'];
|
||||||
|
}
|
||||||
|
$map[$sid] = ny_exam_cell_score($r, $scoreMode);
|
||||||
|
}
|
||||||
|
$scoreByExam[$pid] = $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生并集(在任一选中考试里有提交即纳入)
|
||||||
|
$sidSet = [];
|
||||||
|
foreach ($scoreByExam as $map) {
|
||||||
|
foreach ($map as $sid => $_) {
|
||||||
|
$sidSet[$sid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sids = array_keys($sidSet);
|
||||||
|
// 默认按学号排序(数值优先,否则自然序)
|
||||||
|
usort($sids, static function ($a, $b): int {
|
||||||
|
return ny_sid_cmp((string)$a, (string)$b);
|
||||||
|
});
|
||||||
|
|
||||||
|
$head = ['学号', '姓名'];
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$head[] = $examNames[$pid];
|
||||||
|
}
|
||||||
|
$rows = [];
|
||||||
|
foreach ($sids as $sid) {
|
||||||
|
$sid = (string)$sid;
|
||||||
|
$line = [$sid, $nameBySid[$sid] ?? ''];
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$sc = $scoreByExam[$pid][$sid] ?? null;
|
||||||
|
$line[] = ($sc === null) ? '' : $sc;
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
}
|
||||||
|
return ['head' => $head, 'rows' => $rows, 'exam_order' => $examOrder, 'exam_names' => $examNames];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 排序 / 列显示规则(纯逻辑,可单测) ==================== */
|
||||||
|
|
||||||
|
/** 学号比较:纯数字按数值比,否则自然序 */
|
||||||
|
function ny_sid_cmp(string $a, string $b): int
|
||||||
|
{
|
||||||
|
if (is_numeric($a) && is_numeric($b)) {
|
||||||
|
return $a <=> $b;
|
||||||
|
}
|
||||||
|
return strnatcmp($a, $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本比较:空值恒排末尾(班级 / 组别排序用,避免"未分班"插在中间) */
|
||||||
|
function ny_text_cmp(string $a, string $b): int
|
||||||
|
{
|
||||||
|
if ($a === $b) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ($a === '') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($b === '') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return strnatcmp($a, $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多考试导出「是否补出班级 / 组别列」的判定(按需求)。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 选了 ≥2 个班级 → 出「班级」列;只选 1 个班级 → 不出(全班一样,纯噪音)
|
||||||
|
* - 选了 ≥2 个组别 → 出「组别」列;只选 1 个组别 → 不出
|
||||||
|
* - 未选班级(= 全部)时按实际数据判断:数据确实跨多个班级才出列
|
||||||
|
* - 「只选了 1 个班级且未选组别」→ 班级 / 组别两列都不出
|
||||||
|
*
|
||||||
|
* @param array $clsPick 已选班级([] = 全部)
|
||||||
|
* @param array $grpPick 已选组别([] = 全部)
|
||||||
|
* @param array $seenCls 实际数据里出现的班级
|
||||||
|
* @param array $seenGrp 实际数据里出现的组别
|
||||||
|
* @return array{with_cls:bool,with_grp:bool}
|
||||||
|
*/
|
||||||
|
function ny_multi_col_flags(array $clsPick, array $grpPick, array $seenCls, array $seenGrp): array
|
||||||
|
{
|
||||||
|
$nCls = count($clsPick);
|
||||||
|
$nGrp = count($grpPick);
|
||||||
|
$withCls = ($nCls > 1) || ($nCls === 0 && count($seenCls) > 1);
|
||||||
|
$withGrp = ($nGrp > 1) || ($nGrp === 0 && $nCls !== 1 && count($seenGrp) > 1);
|
||||||
|
return ['with_cls' => $withCls, 'with_grp' => $withGrp];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 「每次考试」块表(两行表头 + 合并单元格) ==================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个学生在某场考试里的「每次考试」列表(按提交时间升序 = 第1次、第2次…)。
|
||||||
|
*
|
||||||
|
* - skipTemp 时草稿不计入、也不占"第几次"编号(导出后 1..N 连续)
|
||||||
|
* - `is_best` 在**被导出的那些次**里比较(跳过草稿时,草稿分不再抢走「是否最佳」标记,
|
||||||
|
* 否则会出现整场没有一次被标「是」的怪表);同分只标最早达成的那次
|
||||||
|
* - 保留草稿时草稿条目的 `draft` 为 true,调用方可据此标注「(草稿)」
|
||||||
|
*
|
||||||
|
* @return array<int,array{idx:int,score:string,ts:int,is_best:bool,draft:bool}>
|
||||||
|
*/
|
||||||
|
function ny_exam_attempt_list(array $r, bool $skipTemp = false): array
|
||||||
|
{
|
||||||
|
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||||
|
if ($list === []) {
|
||||||
|
// 无明细:代表行本身算一次考试
|
||||||
|
$list = [[
|
||||||
|
'idx' => 1,
|
||||||
|
'score' => $r['score'] ?? null,
|
||||||
|
'submit_ts' => (int)($r['submit_ts'] ?? 0),
|
||||||
|
'temp_save' => false,
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
// 统一按提交时间升序(旧 -> 新)
|
||||||
|
usort($list, static function (array $x, array $y): int {
|
||||||
|
return ((int)($x['submit_ts'] ?? 0)) <=> ((int)($y['submit_ts'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 先过滤,再在被导出的那些次里找最高分
|
||||||
|
$kept = [];
|
||||||
|
foreach ($list as $a) {
|
||||||
|
if ($skipTemp && !empty($a['temp_save'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $a;
|
||||||
|
}
|
||||||
|
$bestVal = null;
|
||||||
|
$bestAtIdx = -1;
|
||||||
|
foreach ($kept as $i => $a) {
|
||||||
|
$v = $a['score'] ?? null;
|
||||||
|
if ($v === null || $v === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bestVal === null || (float)$v > $bestVal) {
|
||||||
|
$bestVal = (float)$v;
|
||||||
|
$bestAtIdx = $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($kept as $i => $a) {
|
||||||
|
$score = $a['score'] ?? null;
|
||||||
|
$out[] = [
|
||||||
|
'idx' => $i + 1,
|
||||||
|
'score' => ($score === null || $score === '') ? '' : (string)$score,
|
||||||
|
'ts' => (int)($a['submit_ts'] ?? 0),
|
||||||
|
'is_best' => ($i === $bestAtIdx),
|
||||||
|
'draft' => !empty($a['temp_save']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装「每次考试」块表(两行表头 / 合并单元格 / 未考占位)——与需求截图一致。
|
||||||
|
*
|
||||||
|
* 版式(对应需求截图):
|
||||||
|
* ┌──────┬──────┬────────┬────────┬────────┬────────┐
|
||||||
|
* │ 学号 │ 姓名 │ 第几次 │考试名1 │考试名2 │考试名3 │ ← 第 1 行:考试名
|
||||||
|
* ├──────┼──────┼────────┼────────┼────────┼────────┤
|
||||||
|
* │ │ │ │ 成绩 │ 成绩 │ 成绩 │ ← 第 2 行(前导列纵向贯穿两行)
|
||||||
|
* ├──────┼──────┼────────┼────────┼────────┼────────┤
|
||||||
|
* │24030…│ 张三 │ 1 │ 127 │ 118 │ 134 │
|
||||||
|
* │(合并)│(合并)│ 2 │ 132 │ 121 │ 136 │ ← 最高分用红字
|
||||||
|
* │ │ │ … │ … │ … │ … │
|
||||||
|
* │ │ │合格次数│ 2 │ 0 │ 1 │ ← 达标绿底,未达标不标底
|
||||||
|
* └──────┴──────┴────────┴────────┴────────┴────────┘
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* - 一名学生占 max(各场有效次数) + 1 行(+1 是末尾的「合格次数」汇总行)
|
||||||
|
* - 学号 / 姓名 两列按学生块纵向合并;「第几次」逐行写各自次序号(1、2、3…),**不合并**
|
||||||
|
* (合并会把 2、3 吞掉只留 1,见截图反馈)
|
||||||
|
* - 每场考试占 **1 列**:第 1 行考试名、第 2 行「成绩」小标题
|
||||||
|
* (历史上每场占 2 列,第 2 列是遗留的空位,表现为"每场右边多一根空白列",已去掉)
|
||||||
|
* - 只在该生某场考试完全没考时写「未考」并纵向合并该列的学生块;考了但次数少 → 留空
|
||||||
|
* - 「合格次数」= 该场成绩 ≥ 合格线的次数;始终显示(含 0 次),只有 ≥ 阈值才标绿底
|
||||||
|
* - 每场成绩等于该场最高分的那次用红字标出(同分只标最早达成的一次)
|
||||||
|
*
|
||||||
|
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序 = 考试列顺序)
|
||||||
|
* @param array $opts [
|
||||||
|
* 'skip_temp' => bool, 'with_cls' => bool, 'with_grp' => bool, 'absent_label' => string,
|
||||||
|
* 'pass_line' => float|null, 合格线分数(null = 不判定合格,不出现合格次数行)
|
||||||
|
* 'pass_times' => int, 至少合格几次才算通过(默认 1)
|
||||||
|
* 'mark_best' => bool, 是否把每场最高分标红(默认 true)
|
||||||
|
* 'pass_row' => bool, 是否输出「合格次数」汇总行(默认 true,且需 pass_line 有效)
|
||||||
|
* ]
|
||||||
|
* @return array{rows:array,merges:array,widths:array,header_rows:int,exam_order:array,exam_names:array,exam_col:array,cell_styles:array,students:int,total_rows:int,with_cls:bool,with_grp:bool,pass_row:bool,cols:array}
|
||||||
|
*/
|
||||||
|
function ny_assemble_block_sheet(array $exams, array $opts = []): array
|
||||||
|
{
|
||||||
|
// 默认跳过暂存草稿(草稿不是有效成绩);显式传 skip_temp=false 可保留
|
||||||
|
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
|
||||||
|
$withCls = !empty($opts['with_cls']);
|
||||||
|
$withGrp = !empty($opts['with_grp']);
|
||||||
|
$absent = (string)($opts['absent_label'] ?? '未考');
|
||||||
|
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
|
||||||
|
|
||||||
|
// 合格线:null / 非正数 => 不判定合格(不输出合格次数行)
|
||||||
|
$passLine = $opts['pass_line'] ?? null;
|
||||||
|
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
|
||||||
|
if ($passLine !== null && $passLine <= 0) {
|
||||||
|
$passLine = null;
|
||||||
|
}
|
||||||
|
$passTimes = (int)($opts['pass_times'] ?? 1);
|
||||||
|
if ($passTimes < 1) {
|
||||||
|
$passTimes = 1;
|
||||||
|
}
|
||||||
|
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
|
||||||
|
$passRow = ($wantPassRow && $passLine !== null);
|
||||||
|
|
||||||
|
$examOrder = [];
|
||||||
|
$examNames = [];
|
||||||
|
$stu = []; // sid => ['name','cls','grp','exams' => [pid => 每次考试列表]]
|
||||||
|
|
||||||
|
foreach ($exams as $pid => $ex) {
|
||||||
|
$examOrder[] = $pid;
|
||||||
|
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
|
||||||
|
foreach ((array)($ex['rows'] ?? []) as $r) {
|
||||||
|
$sid = trim((string)($r['sid'] ?? ''));
|
||||||
|
if ($sid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($stu[$sid])) {
|
||||||
|
$stu[$sid] = ['name' => '', 'cls' => '', 'grp' => '', 'exams' => []];
|
||||||
|
}
|
||||||
|
// 同一学生在多场考试里字段一致,取首个非空即可
|
||||||
|
if ($stu[$sid]['name'] === '' && trim((string)($r['name'] ?? '')) !== '') {
|
||||||
|
$stu[$sid]['name'] = (string)$r['name'];
|
||||||
|
}
|
||||||
|
if ($stu[$sid]['cls'] === '' && trim((string)($r['cls'] ?? '')) !== '') {
|
||||||
|
$stu[$sid]['cls'] = (string)$r['cls'];
|
||||||
|
}
|
||||||
|
if ($stu[$sid]['grp'] === '' && trim((string)($r['grp'] ?? '')) !== '') {
|
||||||
|
$stu[$sid]['grp'] = (string)$r['grp'];
|
||||||
|
}
|
||||||
|
$stu[$sid]['exams'][$pid] = ny_exam_attempt_list($r, $skipTemp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生顺序:有班级 / 组别列时按 班级 → 组别 → 学号(同班同组自然相邻,合并单元格才不会断开)
|
||||||
|
$sids = array_keys($stu);
|
||||||
|
usort($sids, static function ($a, $b) use ($stu, $withCls, $withGrp): int {
|
||||||
|
$a = (string)$a; // PHP 会把纯数字学号键转成 int,这里统一回字符串
|
||||||
|
$b = (string)$b;
|
||||||
|
if ($withCls) {
|
||||||
|
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($withGrp) {
|
||||||
|
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ny_sid_cmp($a, $b);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- 列布局 ----------------
|
||||||
|
前导列:班级? / 组别? / 学号 / 姓名 / 第几次(各考试共用一列)
|
||||||
|
考试区:每场 1 列(第 1 行考试名、第 2 行「成绩」小标题) */
|
||||||
|
$colCls = $withCls ? 1 : 0;
|
||||||
|
$colGrp = $withGrp ? ($withCls ? 2 : 1) : 0;
|
||||||
|
$colSid = 1 + ($withCls ? 1 : 0) + ($withGrp ? 1 : 0);
|
||||||
|
$colName = $colSid + 1;
|
||||||
|
$colIdx = $colName + 1; // 「第几次」(所有考试共用)
|
||||||
|
$colExam0 = $colIdx + 1;
|
||||||
|
|
||||||
|
$h1 = [];
|
||||||
|
$h2 = [];
|
||||||
|
if ($withCls) { $h1[] = '班级'; $h2[] = ''; }
|
||||||
|
if ($withGrp) { $h1[] = '组别'; $h2[] = ''; }
|
||||||
|
$h1[] = '学号';
|
||||||
|
$h2[] = '';
|
||||||
|
$h1[] = '姓名';
|
||||||
|
$h2[] = '';
|
||||||
|
$h1[] = '第几次';
|
||||||
|
$h2[] = '';
|
||||||
|
|
||||||
|
$merges = [];
|
||||||
|
// 表头两行的纵向合并:班级 / 组别 / 学号 / 姓名 / 第几次
|
||||||
|
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $c) {
|
||||||
|
if ($c > 0) {
|
||||||
|
$merges[] = [1, $c, 2, $c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每场考试占 1 列:第 1 行考试名、第 2 行「成绩」小标题(无需横向合并)
|
||||||
|
$examCol = [];
|
||||||
|
$c = $colExam0;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$examCol[$pid] = $c;
|
||||||
|
$h1[] = $examNames[$pid];
|
||||||
|
$h2[] = '成绩';
|
||||||
|
$c += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [$h1, $h2];
|
||||||
|
$rn = 2; // 当前行号(两行表头已占 1~2)
|
||||||
|
$total = 0;
|
||||||
|
$blocks = []; // 学生块:['start','end','cls','grp']
|
||||||
|
|
||||||
|
// 单元格标记:'行,列' => 'red' | 'green' | 'header_green'
|
||||||
|
$marks = [];
|
||||||
|
|
||||||
|
foreach ($sids as $sid) {
|
||||||
|
$sid = (string)$sid;
|
||||||
|
$s = $stu[$sid];
|
||||||
|
|
||||||
|
$lists = [];
|
||||||
|
$maxN = 0;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$l = $s['exams'][$pid] ?? [];
|
||||||
|
$lists[$pid] = $l;
|
||||||
|
if (count($l) > $maxN) {
|
||||||
|
$maxN = count($l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($maxN < 1) {
|
||||||
|
continue; // 该生所有考试都没有有效提交(例如只有草稿被跳过)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每场最高分(用于标红;只在被导出的那些次里比较,与列表口径一致)
|
||||||
|
$bestScore = [];
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$bv = null;
|
||||||
|
foreach ($lists[$pid] as $a) {
|
||||||
|
$sv = $a['score'];
|
||||||
|
if ($sv === '' || $sv === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bv === null || (float)$sv > $bv) {
|
||||||
|
$bv = (float)$sv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bestScore[$pid] = $bv;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $rn + 1;
|
||||||
|
for ($i = 0; $i < $maxN; $i++) {
|
||||||
|
$rn++;
|
||||||
|
$line = [];
|
||||||
|
if ($withCls) {
|
||||||
|
$line[] = ($i === 0) ? $s['cls'] : '';
|
||||||
|
}
|
||||||
|
if ($withGrp) {
|
||||||
|
$line[] = ($i === 0) ? $s['grp'] : '';
|
||||||
|
}
|
||||||
|
$line[] = ($i === 0) ? $sid : '';
|
||||||
|
$line[] = ($i === 0) ? $s['name'] : '';
|
||||||
|
$line[] = (string)($i + 1); // 第几次:所有考试共用一列
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$a = $lists[$pid][$i] ?? null;
|
||||||
|
if ($a === null) {
|
||||||
|
$line[] = ''; // 该生这场没考到第 i 次 → 留空
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$line[] = $a['score'];
|
||||||
|
// 最高分标红:只标一处(同分只标最早达成的那一行)
|
||||||
|
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
|
||||||
|
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
|
||||||
|
$marks[$rn . ',' . $examCol[$pid]] = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
$end = $rn;
|
||||||
|
|
||||||
|
if ($end > $start) {
|
||||||
|
$merges[] = [$start, $colSid, $end, $colSid];
|
||||||
|
$merges[] = [$start, $colName, $end, $colName];
|
||||||
|
// 「第几次」不合并:每行是不同的次序号(合并只剩第一行的 1)
|
||||||
|
}
|
||||||
|
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
if ($lists[$pid] !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cc = $examCol[$pid];
|
||||||
|
$rows[$start - 1][$cc - 1] = $absent;
|
||||||
|
if ($end > $start) { // 单行块无需合并(1×1 合并是非法区间,Excel 会提示修复)
|
||||||
|
$merges[] = [$start, $cc, $end, $cc];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocks[] = ['start' => $start, 'end' => $end, 'cls' => $s['cls'], 'grp' => $s['grp']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 末尾「合格次数」汇总行 ----------------
|
||||||
|
每个学生块末尾追加一行「合格次数」:每场考试列出该生达到合格线的次数;
|
||||||
|
次数始终显示(含 0 次),达到阈值(默认 1 次)才标绿底。
|
||||||
|
|
||||||
|
追加行会改变所有行号,因此这里不走"插入",而是带着合格次数行整体重建一遍
|
||||||
|
(学生块高 = maxN + 1),保证合并区间 / 标红位置的坐标一次算对。 */
|
||||||
|
if ($passRow) {
|
||||||
|
$rows = [$h1, $h2];
|
||||||
|
$merges = [];
|
||||||
|
$marks = [];
|
||||||
|
$rn = 2;
|
||||||
|
$total = 0;
|
||||||
|
$newBlocks = [];
|
||||||
|
foreach ([$colCls, $colGrp, $colSid, $colName, $colIdx] as $cc) {
|
||||||
|
if ($cc > 0) {
|
||||||
|
$merges[] = [1, $cc, 2, $cc];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 考试列每场 1 列,表头两行各写「考试名 / 成绩」,无需合并
|
||||||
|
|
||||||
|
foreach ($sids as $sid) {
|
||||||
|
$sid = (string)$sid;
|
||||||
|
$s = $stu[$sid];
|
||||||
|
$lists = [];
|
||||||
|
$maxN = 0;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$l = $s['exams'][$pid] ?? [];
|
||||||
|
$lists[$pid] = $l;
|
||||||
|
if (count($l) > $maxN) {
|
||||||
|
$maxN = count($l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($maxN < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bestScore = [];
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$bv = null;
|
||||||
|
foreach ($lists[$pid] as $a) {
|
||||||
|
$sv = $a['score'];
|
||||||
|
if ($sv === '' || $sv === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bv === null || (float)$sv > $bv) {
|
||||||
|
$bv = (float)$sv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bestScore[$pid] = $bv;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $rn + 1;
|
||||||
|
for ($i = 0; $i < $maxN; $i++) {
|
||||||
|
$rn++;
|
||||||
|
$line = [];
|
||||||
|
if ($withCls) { $line[] = ($i === 0) ? $s['cls'] : ''; }
|
||||||
|
if ($withGrp) { $line[] = ($i === 0) ? $s['grp'] : ''; }
|
||||||
|
$line[] = ($i === 0) ? $sid : '';
|
||||||
|
$line[] = ($i === 0) ? $s['name'] : '';
|
||||||
|
$line[] = (string)($i + 1); // 第几次:逐行写各自次序号,不合并
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$a = $lists[$pid][$i] ?? null;
|
||||||
|
if ($a === null) {
|
||||||
|
$line[] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$line[] = $a['score'];
|
||||||
|
if ($markBest && $a['score'] !== '' && $bestScore[$pid] !== null
|
||||||
|
&& (float)$a['score'] === $bestScore[$pid] && $a['is_best']) {
|
||||||
|
$marks[$rn . ',' . $examCol[$pid]] = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完全没考的考试:该场该列 × 学生块高 → 合并成「未考」
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
if ($lists[$pid] !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cx = $examCol[$pid];
|
||||||
|
$rows[$start - 1][$cx - 1] = $absent;
|
||||||
|
if ($rn > $start) { // 单行块无需合并(1×1 合并是非法区间)
|
||||||
|
$merges[] = [$start, $cx, $rn, $cx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合格次数行(每个学生块一行)
|
||||||
|
$rn++;
|
||||||
|
$passLineRow = [];
|
||||||
|
if ($withCls) { $passLineRow[] = ''; }
|
||||||
|
if ($withGrp) { $passLineRow[] = ''; }
|
||||||
|
$passLineRow[] = '';
|
||||||
|
$passLineRow[] = '';
|
||||||
|
$passLineRow[] = '合格次数'; // 落在「第几次」列,与截图一致
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$list = $lists[$pid];
|
||||||
|
if ($list === []) {
|
||||||
|
// 整场未考:与「未考」合并格保持一致,不再单独判断合格次数
|
||||||
|
$passLineRow[] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cnt = 0;
|
||||||
|
foreach ($list as $a) {
|
||||||
|
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
|
||||||
|
$cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 合格次数始终显示(含 0 次);只有达到阈值才标绿底。
|
||||||
|
之前写 '' 会把「考了但没达标」的人整格隐藏,用户要看全部人次。 */
|
||||||
|
$passLineRow[] = (string)$cnt;
|
||||||
|
if ($cnt >= $passTimes) {
|
||||||
|
$marks[$rn . ',' . $examCol[$pid]] = 'green'; // 达标:绿底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $passLineRow;
|
||||||
|
$total++;
|
||||||
|
|
||||||
|
/* 学号 / 姓名 纵向合并到「合格次数」行(这两列该行本就是空的,合并正确);
|
||||||
|
「第几次」列逐行是 1、2、3…,不合并——末行「合格次数」标签也写在这一列。 */
|
||||||
|
$merges[] = [$start, $colSid, $rn, $colSid];
|
||||||
|
$merges[] = [$start, $colName, $rn, $colName];
|
||||||
|
$newBlocks[] = ['start' => $start, 'end' => $rn, 'cls' => $s['cls'], 'grp' => $s['grp']];
|
||||||
|
}
|
||||||
|
$blocks = $newBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级 / 组别:连续相同值的相邻学生块合并成一格
|
||||||
|
foreach ([['cls', $colCls], ['grp', $colGrp]] as $spec) {
|
||||||
|
$key = $spec[0];
|
||||||
|
$col = (int)$spec[1];
|
||||||
|
if ($col < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$n = count($blocks);
|
||||||
|
$i = 0;
|
||||||
|
while ($i < $n) {
|
||||||
|
$val = $blocks[$i][$key];
|
||||||
|
$j = $i;
|
||||||
|
while ($j + 1 < $n && $blocks[$j + 1][$key] === $val) {
|
||||||
|
$j++;
|
||||||
|
}
|
||||||
|
if ($val !== '' && $j > $i) {
|
||||||
|
$merges[] = [$blocks[$i]['start'], $col, $blocks[$j]['end'], $col];
|
||||||
|
}
|
||||||
|
$i = $j + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并区间去重(重复区间会让 Excel 打开时提示"修复")
|
||||||
|
$seen = [];
|
||||||
|
$uniq = [];
|
||||||
|
foreach ($merges as $m) {
|
||||||
|
$k = $m[0] . ',' . $m[1] . ',' . $m[2] . ',' . $m[3];
|
||||||
|
if (isset($seen[$k])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$k] = true;
|
||||||
|
$uniq[] = $m;
|
||||||
|
}
|
||||||
|
$merges = $uniq;
|
||||||
|
|
||||||
|
/* 列宽:够用就好,宽了会把人挤到屏幕外(截图里学号列两侧大片空白就是这么来的)。
|
||||||
|
- 学号按实际内容估宽(10 位数字约 11,超过 12 位才放宽),不再一律 20
|
||||||
|
- 姓名同样按最长姓名估宽
|
||||||
|
- 每场考试只占 1 列,列宽要装得下该列表头(考试名),中文按 2 个半角宽算 */
|
||||||
|
$maxSidLen = 2;
|
||||||
|
$maxNameLen = 2;
|
||||||
|
foreach ($stu as $s) {
|
||||||
|
// 名单里可能缺 sid / name 的条目(脏数据):估宽只能兜住,不能因此报 Undefined key
|
||||||
|
$sidLen = mb_strlen((string)($s['sid'] ?? ''), 'UTF-8');
|
||||||
|
$nameLen = mb_strlen((string)($s['name'] ?? ''), 'UTF-8');
|
||||||
|
if ($sidLen > $maxSidLen) {
|
||||||
|
$maxSidLen = $sidLen;
|
||||||
|
}
|
||||||
|
if ($nameLen > $maxNameLen) {
|
||||||
|
$maxNameLen = $nameLen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Excel 列宽单位≈半角字符数;中文按 2 个半角宽算,再留 2 的余量
|
||||||
|
$sidW = min(18, max(10, $maxSidLen + 2));
|
||||||
|
$nameW = min(20, max(9, $maxNameLen * 2 + 2));
|
||||||
|
|
||||||
|
// 考试列:取最长的考试名估一个统一宽度
|
||||||
|
$examW = 8;
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$nm = (string)$examNames[$pid];
|
||||||
|
$len = mb_strlen($nm, 'UTF-8');
|
||||||
|
$w = 0;
|
||||||
|
for ($k = 0; $k < $len; $k++) {
|
||||||
|
$w += (strlen((string)mb_substr($nm, $k, 1, 'UTF-8')) > 1) ? 2 : 1;
|
||||||
|
}
|
||||||
|
$w += 2; // 左右留白
|
||||||
|
if ($w > $examW) {
|
||||||
|
$examW = $w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$examW = min(18, max(8, $examW));
|
||||||
|
|
||||||
|
$widths = [];
|
||||||
|
if ($withCls) {
|
||||||
|
$widths[] = 14; // 班级名通常较长(如「计算机2401」)
|
||||||
|
}
|
||||||
|
if ($withGrp) {
|
||||||
|
$widths[] = 9;
|
||||||
|
}
|
||||||
|
$widths[] = $sidW; // 学号
|
||||||
|
$widths[] = $nameW; // 姓名
|
||||||
|
$widths[] = 10; // 第几次(同时承载「合格次数」标签)
|
||||||
|
foreach ($examOrder as $pid) {
|
||||||
|
$widths[] = $examW; // 每场考试 1 列(列宽装得下表头考试名)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'merges' => $merges,
|
||||||
|
'widths' => $widths,
|
||||||
|
'header_rows' => 2,
|
||||||
|
'exam_order' => $examOrder,
|
||||||
|
'exam_names' => $examNames,
|
||||||
|
'exam_col' => $examCol,
|
||||||
|
'cell_styles' => $marks,
|
||||||
|
'students' => count($blocks),
|
||||||
|
'total_rows' => $total,
|
||||||
|
'with_cls' => $withCls,
|
||||||
|
'with_grp' => $withGrp,
|
||||||
|
'pass_row' => $passRow,
|
||||||
|
'pass_line' => $passLine,
|
||||||
|
'pass_times' => $passTimes,
|
||||||
|
'cols' => [
|
||||||
|
'cls' => $colCls, 'grp' => $colGrp, 'sid' => $colSid,
|
||||||
|
'name' => $colName, 'idx' => $colIdx, 'exam0' => $colExam0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装「单场考试的每次考试」宽表——对应需求截图二。
|
||||||
|
*
|
||||||
|
* 版式:
|
||||||
|
* ┌──────┬──────┬──────┬──────┬─────┬────────┐
|
||||||
|
* │ 学号 │ 姓名 │ 第1次│ 第2次│ ... │合格次数│
|
||||||
|
* ├──────┼──────┼──────┼──────┼─────┼────────┤
|
||||||
|
* │24030…│ 张三 │ 127 │ 132 │ 143 │ 2 │ ← 最高分红字、合格次数达标绿底
|
||||||
|
* └──────┴──────┴──────┴──────┴─────┴────────┘
|
||||||
|
*
|
||||||
|
* 每人一行;列数 = 2 + 该场最多考试次数 + 1(合格次数)。
|
||||||
|
* 某生次数少于最大值时留空。仅在"不带考试耗时 / 提交时间"时使用本版式,
|
||||||
|
* 带了耗时 / 时间就会失去宽表的意义(每人一行放不下每次的耗时),此时退回明细表。
|
||||||
|
*
|
||||||
|
* @param array $exam 单场考试 ['name'=>string, 'rows'=>array]
|
||||||
|
* @param array $opts ['skip_temp'=>bool, 'pass_line'=>float|null, 'pass_times'=>int, 'mark_best'=>bool, 'pass_row'=>bool]
|
||||||
|
* @return array{rows:array,merges:array,widths:array,header_rows:int,cell_styles:array,students:int,total_rows:int,attempt_cols:int,pass_row:bool}
|
||||||
|
*/
|
||||||
|
function ny_assemble_attempt_wide_sheet(array $exam, array $opts = []): array
|
||||||
|
{
|
||||||
|
$skipTemp = array_key_exists('skip_temp', $opts) ? !empty($opts['skip_temp']) : true;
|
||||||
|
$markBest = array_key_exists('mark_best', $opts) ? !empty($opts['mark_best']) : true;
|
||||||
|
$passLine = $opts['pass_line'] ?? null;
|
||||||
|
$passLine = ($passLine === null || $passLine === '') ? null : (float)$passLine;
|
||||||
|
if ($passLine !== null && $passLine <= 0) {
|
||||||
|
$passLine = null;
|
||||||
|
}
|
||||||
|
$passTimes = max(1, (int)($opts['pass_times'] ?? 1));
|
||||||
|
$wantPassRow = array_key_exists('pass_row', $opts) ? !empty($opts['pass_row']) : true;
|
||||||
|
$passRow = ($wantPassRow && $passLine !== null);
|
||||||
|
|
||||||
|
$stu = [];
|
||||||
|
foreach ((array)($exam['rows'] ?? []) as $r) {
|
||||||
|
$sid = trim((string)($r['sid'] ?? ''));
|
||||||
|
if ($sid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($stu[$sid])) {
|
||||||
|
$stu[$sid] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => (string)($r['name'] ?? ''),
|
||||||
|
'cls' => (string)($r['cls'] ?? ''),
|
||||||
|
'grp' => (string)($r['grp'] ?? ''),
|
||||||
|
'list' => ny_exam_attempt_list($r, $skipTemp),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sids = array_keys($stu);
|
||||||
|
usort($sids, static function ($a, $b) use ($stu): int {
|
||||||
|
$a = (string)$a;
|
||||||
|
$b = (string)$b;
|
||||||
|
$d = ny_text_cmp($stu[$a]['cls'], $stu[$b]['cls']);
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
$d = ny_text_cmp($stu[$a]['grp'], $stu[$b]['grp']);
|
||||||
|
if ($d !== 0) {
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
return ny_sid_cmp($a, $b);
|
||||||
|
});
|
||||||
|
|
||||||
|
$maxN = 0;
|
||||||
|
foreach ($sids as $sid) {
|
||||||
|
$n = count($stu[$sid]['list']);
|
||||||
|
if ($n > $maxN) {
|
||||||
|
$maxN = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($maxN < 1) {
|
||||||
|
$maxN = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$head = ['学号', '姓名'];
|
||||||
|
for ($i = 1; $i <= $maxN; $i++) {
|
||||||
|
$head[] = '第' . $i . '次';
|
||||||
|
}
|
||||||
|
if ($passRow) {
|
||||||
|
$head[] = '合格次数';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [$head];
|
||||||
|
$marks = [];
|
||||||
|
$rn = 1;
|
||||||
|
$colPass = 2 + $maxN + 1; // 合格次数列(1-based)
|
||||||
|
|
||||||
|
foreach ($sids as $sid) {
|
||||||
|
$sid = (string)$sid;
|
||||||
|
$s = $stu[$sid];
|
||||||
|
$list = $s['list'];
|
||||||
|
$rn++;
|
||||||
|
|
||||||
|
$line = [$sid, $s['name']];
|
||||||
|
$best = null;
|
||||||
|
foreach ($list as $a) {
|
||||||
|
if ($a['score'] === '' || $a['score'] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$v = (float)$a['score'];
|
||||||
|
if ($best === null || $v > $best) {
|
||||||
|
$best = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ($i = 0; $i < $maxN; $i++) {
|
||||||
|
$a = $list[$i] ?? null;
|
||||||
|
if ($a === null) {
|
||||||
|
$line[] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$line[] = $a['score'];
|
||||||
|
if ($markBest && $a['score'] !== '' && $best !== null
|
||||||
|
&& (float)$a['score'] === $best && $a['is_best']) {
|
||||||
|
$marks[$rn . ',' . (3 + $i)] = 'red'; // 第1次在第 3 列
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($passRow) {
|
||||||
|
$cnt = 0;
|
||||||
|
foreach ($list as $a) {
|
||||||
|
if ($a['score'] !== '' && $a['score'] !== null && (float)$a['score'] >= $passLine) {
|
||||||
|
$cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 合格次数始终显示(含 0 次);只有达到阈值才标绿底
|
||||||
|
$line[] = (string)$cnt;
|
||||||
|
if ($cnt >= $passTimes) {
|
||||||
|
$marks[$rn . ',' . $colPass] = 'green';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
$widths = [20, 14];
|
||||||
|
for ($i = 0; $i < $maxN; $i++) {
|
||||||
|
$widths[] = 9;
|
||||||
|
}
|
||||||
|
if ($passRow) {
|
||||||
|
$widths[] = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'merges' => [],
|
||||||
|
'widths' => $widths,
|
||||||
|
'header_rows' => 1,
|
||||||
|
'cell_styles' => $marks,
|
||||||
|
'students' => count($sids),
|
||||||
|
'total_rows' => count($sids),
|
||||||
|
'attempt_cols' => $maxN,
|
||||||
|
'pass_row' => $passRow,
|
||||||
|
'pass_line' => $passLine,
|
||||||
|
'pass_times' => $passTimes,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,21 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在安装向导配置,
|
* 独立登录页:连接信息(系统地址/接口前缀/账号)已在 data/config.json 中预置,
|
||||||
* 本页仅需输入密码(账号可改)完成卷王登录,成功后进入主系统。
|
* 本页只需输入密码(账号可改)完成卷王登录,成功后进入主系统。
|
||||||
|
* 连接信息有变请在【设置】中修改。
|
||||||
*/
|
*/
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好
|
||||||
|
$cfg = ny_load_config();
|
||||||
|
|
||||||
|
$notice = ''; // 非阻断提示(例如"上次会话已失效,请重新登录")
|
||||||
|
|
||||||
if (ny_is_auth()) {
|
if (ny_is_auth()) {
|
||||||
|
// 已登录但卷王 token 可能已过期:用当前 token 探一次卷王,能通即视为有效,否则清掉本地会话强制重新登录
|
||||||
|
try {
|
||||||
|
$client = new SurveyKingClient($cfg);
|
||||||
|
$client->ping();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 卷王 token 已失效或配置错误 → 清会话 + 强制显示登录页(避免 login.php 把人踢回 index.php 形成死循环)
|
||||||
|
// 把失败原因带出来,避免用户只看到登录框却不知道为什么被踢回来
|
||||||
|
$notice = '已退出之前的登录(' . $e->getMessage() . ')';
|
||||||
|
$_SESSION = [];
|
||||||
|
@session_destroy();
|
||||||
|
}
|
||||||
|
if (ny_is_auth()) {
|
||||||
header('Location: index.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if (!ny_is_installed()) {
|
|
||||||
header('Location: install.php');
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cfg = ny_load_config();
|
|
||||||
$err = '';
|
$err = '';
|
||||||
$ok = false;
|
$ok = false;
|
||||||
|
|
||||||
@@ -33,11 +47,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
try {
|
try {
|
||||||
$client = new SurveyKingClient($test);
|
$client = new SurveyKingClient($test);
|
||||||
$client->getToken(true);
|
$client->getToken(true);
|
||||||
// 登录成功:把账号写回配置(如需),并建立会话
|
|
||||||
|
// 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token
|
||||||
|
// 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态)
|
||||||
$cfg['username'] = $user;
|
$cfg['username'] = $user;
|
||||||
ny_save_config($cfg);
|
$cfg['password'] = $pwd;
|
||||||
|
$saved = ny_save_config($cfg);
|
||||||
|
|
||||||
|
// 关键:必须回读确认真的存住了。以前这里不看返回值,data 目录不可写时
|
||||||
|
// 会显示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳回登录页。
|
||||||
|
$back = ny_load_config();
|
||||||
|
$persistOk = $saved
|
||||||
|
&& trim((string)($back['username'] ?? '')) === $user
|
||||||
|
&& (string)($back['password'] ?? '') === $pwd;
|
||||||
|
|
||||||
|
if (!$persistOk) {
|
||||||
|
$d = ny_config_diag();
|
||||||
|
$err = "登录卷王成功,但凭据没能保存到本机配置,继续进入会立刻又提示“未配置账号”。\n"
|
||||||
|
. "请检查 data 目录写权限后重试:\n"
|
||||||
|
. "· 配置来源:" . $d['storage'] . "(可写=" . ($d['cfg_writable'] ? '是' : '否') . ")\n"
|
||||||
|
. "· 数据目录:" . $d['data_dir'] . "(可写=" . ($d['dir_writable'] ? '是' : '否') . ")\n"
|
||||||
|
. "· 数据库:" . ($d['db_connected'] ? '已连接(可写=' . ($d['db_writable'] ? '是' : '否') . ')' : '未连接 ' . $d['db_error']) . "\n"
|
||||||
|
. "· 保存错误:" . ($d['save_error'] !== '' ? $d['save_error'] : '(无)');
|
||||||
|
} elseif (!ny_session_usable($sessWhy)) {
|
||||||
|
// 会话存不住时同样会"登录成功→立刻被踢回登录页",必须在这一步拦住
|
||||||
|
$err = "凭据已保存,但 PHP 会话不可用,登录状态无法保持:\n" . $sessWhy
|
||||||
|
. "\n请在 php.ini 中把 session.save_path 指向一个可写目录后重试。";
|
||||||
|
} else {
|
||||||
ny_login();
|
ny_login();
|
||||||
$ok = true;
|
$ok = true;
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$err = '登录失败:' . $e->getMessage();
|
$err = '登录失败:' . $e->getMessage();
|
||||||
}
|
}
|
||||||
@@ -63,9 +102,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
<p class="login-tip">系统连接信息已配置(<?= htmlspecialchars(parse_url($cfg['base_url'], PHP_URL_HOST)) ?>/<?= htmlspecialchars(trim($cfg['api_prefix'], '/')) ?>)。请输入账号密码完成登录。</p>
|
||||||
|
<?php if (trim((string)$cfg['username']) === ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap">⚠️ 本机尚未保存卷王账号(配置来源:<?= htmlspecialchars(ny_config_source()) ?>)。
|
||||||
|
如果每次登录成功都回到本页,多半是 data 目录不可写导致账号没存住——
|
||||||
|
请让 data/ 目录(<?= htmlspecialchars(NY_DATA) ?>)对 PHP 进程可写后重试。</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($err !== ''): ?>
|
<?php if ($err !== ''): ?>
|
||||||
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($err) ?></div>
|
||||||
|
<?php elseif ($notice !== ''): ?>
|
||||||
|
<div class="alert" style="white-space:pre-wrap"><?= htmlspecialchars($notice) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -75,7 +121,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<button class="btn btn-primary" type="submit">登录</button>
|
<button class="btn btn-primary" type="submit">登录</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="forgot"><span style="font-size:12px;color:var(--text-3)">如系统连接信息已变更,请先完成<a href="install.php?force=1" style="color:var(--primary)">重新安装/配置</a></span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +130,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
<div class="login-brand" style="justify-content:center"><span class="logo">NY</span>
|
||||||
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
<div class="brand-text"><strong>登录成功</strong><small>正在进入数据平台…</small></div>
|
||||||
</div>
|
</div>
|
||||||
<script>setTimeout(function(){ location.href='index.php'; }, 600);</script>
|
<script>
|
||||||
|
// 记录"本次登录成功"的时间戳:前端据此判断"刚登录完又报鉴权失败",
|
||||||
|
// 这种情况不再自动跳回登录页(否则会形成死循环),而是把真实错误显示出来。
|
||||||
|
try { sessionStorage.setItem('ny_login_at', String(Date.now())); sessionStorage.removeItem('ny_auth_jumps'); } catch (e) {}
|
||||||
|
setTimeout(function(){ location.href='index.php'; }, 600);
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键跑完所有回归测试(PHP 单元自测 + Node e2e)。
|
||||||
|
# 用法:bash tests/run-all.sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# 运行期路径:优先用环境变量,其次用本机工作区路径,最后退回 PATH 里的 php / node
|
||||||
|
# (工作区 node 的版本目录会随升级变化,写死路径会直接 No such file or directory)
|
||||||
|
pick() {
|
||||||
|
local cand="$1" fallback="$2"
|
||||||
|
if [ -n "$cand" ] && [ -x "$cand" ]; then echo "$cand"; return; fi
|
||||||
|
command -v "$fallback" 2>/dev/null || echo "$fallback"
|
||||||
|
}
|
||||||
|
# 本项目用 PHP 7.4+ 语法(箭头函数 / 类型化属性),必须是 PHP 8;
|
||||||
|
# 工作区里已备好便携版 8.2.34(其 php.ini 已开启 pdo_sqlite / zip / mbstring),
|
||||||
|
# 想换别的解释器就设 PHP_BIN=... 覆盖
|
||||||
|
PHP="$(pick "${PHP_BIN:-C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe}" php)"
|
||||||
|
NODE="$(pick "${NODE_BIN:-}" node)"
|
||||||
|
INI="${PHP_INI:-}"
|
||||||
|
echo "PHP = $PHP"
|
||||||
|
echo "NODE = $NODE"
|
||||||
|
|
||||||
|
echo "================ PHP 自测 ================"
|
||||||
|
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php tests/test-multi-export.php; do
|
||||||
|
echo ""
|
||||||
|
echo ">>> $f"
|
||||||
|
"$PHP" $INI "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================"
|
||||||
|
# e2e 会把 mock 连接配置写进数据目录;用 NY_DATA 把这两个会改写配置的测试整体指向临时目录,
|
||||||
|
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||||
|
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||||
|
mkdir -p "$NY_DATA_TMP"
|
||||||
|
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
|
||||||
|
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
|
||||||
|
# 这几段依赖 mock-server(dashboard-nav 自带名单,另 4 段需本机 data/roster.json)。
|
||||||
|
# 逐段容错,避免一个坏掉的环境把后面的测试整段带走;失败照常记账,末尾统一汇总退出码。
|
||||||
|
e2e_fail=0
|
||||||
|
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js tools/test-dashboard-nav.js; do
|
||||||
|
echo ""
|
||||||
|
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
||||||
|
if ! NY_DATA="$NY_DATA_TMP" "$NODE" "$f"; then e2e_fail=$((e2e_fail + 1)); fi
|
||||||
|
done
|
||||||
|
# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可
|
||||||
|
echo ""
|
||||||
|
echo ">>> tools/test-draw-alloc.js"
|
||||||
|
"$NODE" "tools/test-draw-alloc.js" || e2e_fail=$((e2e_fail + 1))
|
||||||
|
|
||||||
|
# 多考试打印渲染(纯前端函数,不需要服务器):从 app.js 抽取 sheetToHtml 校验合并单元格换算
|
||||||
|
echo ""
|
||||||
|
echo ">>> tools/test-block-print.js"
|
||||||
|
"$NODE" "tools/test-block-print.js" || e2e_fail=$((e2e_fail + 1))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$e2e_fail" -gt 0 ]; then
|
||||||
|
echo "全部测试跑完,但有 $e2e_fail 个 Node 段失败(多为 mock-server 缺 data/roster.json 导致的环境问题)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "全部测试完成。"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 验证 aggregateByStudent 的"考试次数 / 历史明细"修复:
|
||||||
|
* 多次提交的学生在 dedupe=true(默认)与 dedupe=false 两种情况下,
|
||||||
|
* attempts 都应等于真实提交次数、attempts_list 都应包含全部提交;
|
||||||
|
* dedupe 仅影响"代表成绩"那一行取最新或最高分。
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
$fail = 0;
|
||||||
|
$pass = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $fail, $pass;
|
||||||
|
if ($cond) {
|
||||||
|
$pass++;
|
||||||
|
echo " ✅ {$name}\n";
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
echo " ❌ {$name} {$detail}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一个学生的多次提交(模拟 第二周测试 中"陈苏豫 11次"这类数据)
|
||||||
|
* 提交时间递增(旧 -> 新),分数有高有低。
|
||||||
|
*/
|
||||||
|
function makeRows(string $sid, array $scoresByTime): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$t = 1000; // 递增时间戳
|
||||||
|
foreach ($scoresByTime as $sc) {
|
||||||
|
$t += 600;
|
||||||
|
$rows[] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => '测试生',
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => $sc,
|
||||||
|
'submit_ts' => $t,
|
||||||
|
'submit_at' => date('Y-m-d H:i:s', $t),
|
||||||
|
'duration' => 120,
|
||||||
|
'duration_txt'=> '2分0秒',
|
||||||
|
'answer_id' => 'ans-' . $t,
|
||||||
|
'matched' => true,
|
||||||
|
'temp_save' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== dedupe=true(默认:代表成绩取最新一份)==\n";
|
||||||
|
// 11 次提交:分数各不相同,最新一份是 84.0,最高是 98.0
|
||||||
|
$scores = [84.0, 98.0, 96.0, 96.0, 96.0, 94.0, 94.0, 96.0, 90.0, 88.0, 98.0];
|
||||||
|
$rows = makeRows('S001', $scores);
|
||||||
|
$agg = DataService::aggregateByStudent($rows, true);
|
||||||
|
check('聚合后只有 1 行(一人一行)', count($agg['rows']) === 1);
|
||||||
|
$r = $agg['rows'][0];
|
||||||
|
check('attempts = 11(真实次数,而非被吞成 1)', ($r['attempts'] ?? 0) === 11, 'got ' . var_export($r['attempts'] ?? null, true));
|
||||||
|
check('attempts_list 含 11 条(历史可展开全部)', count($r['attempts_list'] ?? []) === 11, 'got ' . count($r['attempts_list'] ?? []));
|
||||||
|
check('best_score = 98.0(跨全部尝试的真实最高分)', (float)($r['best_score'] ?? -1) === 98.0, 'got ' . var_export($r['best_score'] ?? null, true));
|
||||||
|
// 代表成绩 = 最新一份(数组中最后一个时间 = 98.0,因为 scores 数组最后一个是 98.0;注意最新提交对应最后构造的 98.0)
|
||||||
|
check('代表成绩取最新提交 = 98.0', (float)($r['score'] ?? -1) === 98.0, 'got ' . var_export($r['score'] ?? null, true));
|
||||||
|
check('attempts_list 项数 idx 连续 1..11', (int)($r['attempts_list'][10]['idx'] ?? 0) === 11);
|
||||||
|
check('attempts_list 至少一条 is_best(最高分标记)', count(array_filter($r['attempts_list'], fn($a) => !empty($a['is_best']))) >= 1);
|
||||||
|
|
||||||
|
echo "\n== dedupe=false(代表成绩取最高分)==\n";
|
||||||
|
$agg2 = DataService::aggregateByStudent($rows, false);
|
||||||
|
$r2 = $agg2['rows'][0];
|
||||||
|
check('attempts = 11(与 dedupe 无关,始终真实)', ($r2['attempts'] ?? 0) === 11, 'got ' . var_export($r2['attempts'] ?? null, true));
|
||||||
|
check('attempts_list 含 11 条', count($r2['attempts_list'] ?? []) === 11, 'got ' . count($r2['attempts_list'] ?? []));
|
||||||
|
check('代表成绩取最高分 = 98.0', (float)($r2['score'] ?? -1) === 98.0, 'got ' . var_export($r2['score'] ?? null, true));
|
||||||
|
check('best_score = 98.0', (float)($r2['best_score'] ?? -1) === 98.0, 'got ' . var_export($r2['best_score'] ?? null, true));
|
||||||
|
|
||||||
|
echo "\n== 重考低分也应被统计(历史含低分,且因提交时间更新能被发现)==\n";
|
||||||
|
// 先考 90,后重考 70(更低):考试次数必须=2,最新提交=70
|
||||||
|
$rows3 = makeRows('S002', [90.0, 70.0]);
|
||||||
|
$agg3 = DataService::aggregateByStudent($rows3, true);
|
||||||
|
$r3 = $agg3['rows'][0];
|
||||||
|
check('attempts = 2', ($r3['attempts'] ?? 0) === 2, 'got ' . var_export($r3['attempts'] ?? null, true));
|
||||||
|
check('attempts_list = 2 条(含低分重考)', count($r3['attempts_list'] ?? []) === 2);
|
||||||
|
check('代表成绩取最新 = 70.0', (float)($r3['score'] ?? -1) === 70.0, 'got ' . var_export($r3['score'] ?? null, true));
|
||||||
|
check('last_submit_ts 指向最新提交(重考可被高亮发现)', ($r3['last_submit_ts'] ?? 0) === $rows3[1]['submit_ts']);
|
||||||
|
|
||||||
|
echo "\n== 仅 1 次提交不受影响 ==\n";
|
||||||
|
$rows4 = makeRows('S003', [85.0]);
|
||||||
|
$agg4 = DataService::aggregateByStudent($rows4, true);
|
||||||
|
$r4 = $agg4['rows'][0];
|
||||||
|
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
|
||||||
|
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
|
||||||
|
|
||||||
|
echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n";
|
||||||
|
/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */
|
||||||
|
function makeRowsAt(string $sid, array $items): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
[$ts, $sc] = $it;
|
||||||
|
$rows[] = [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => '测试生',
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => $sc,
|
||||||
|
'submit_ts' => $ts,
|
||||||
|
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
|
||||||
|
'duration' => 120,
|
||||||
|
'duration_txt' => '2分0秒',
|
||||||
|
'answer_id' => 'ans-' . $ts,
|
||||||
|
'matched' => true,
|
||||||
|
'temp_save' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
// 09-08 三次(70/95/88),09-09 两次(60/90)
|
||||||
|
$rows5 = makeRowsAt('S005', [
|
||||||
|
[strtotime('2026-09-08 09:00:00'), 70.0],
|
||||||
|
[strtotime('2026-09-08 11:00:00'), 95.0],
|
||||||
|
[strtotime('2026-09-08 15:00:00'), 88.0],
|
||||||
|
[strtotime('2026-09-09 10:00:00'), 60.0],
|
||||||
|
[strtotime('2026-09-09 14:00:00'), 90.0],
|
||||||
|
]);
|
||||||
|
$agg5 = DataService::aggregateByStudent($rows5, true);
|
||||||
|
$r5 = $agg5['rows'][0];
|
||||||
|
$al5 = $r5['attempts_list'];
|
||||||
|
check('5 次提交 attempts_list = 5', count($al5) === 5);
|
||||||
|
check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08',
|
||||||
|
'got ' . ($al5[0]['day'] ?? '?'));
|
||||||
|
check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09',
|
||||||
|
'got ' . ($al5[3]['day'] ?? '?'));
|
||||||
|
check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?'));
|
||||||
|
check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?'));
|
||||||
|
$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best'])));
|
||||||
|
check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx));
|
||||||
|
check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0,
|
||||||
|
'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?'));
|
||||||
|
check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0,
|
||||||
|
'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?'));
|
||||||
|
check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best']));
|
||||||
|
|
||||||
|
echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n";
|
||||||
|
$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]);
|
||||||
|
$agg6 = DataService::aggregateByStudent($rows6, true);
|
||||||
|
$al6 = $agg6['rows'][0]['attempts_list'];
|
||||||
|
check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '',
|
||||||
|
'got ' . var_export($al6[0]['day'] ?? null, true));
|
||||||
|
check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?'));
|
||||||
|
check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0);
|
||||||
|
|
||||||
|
echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n";
|
||||||
|
check('00:30 仍算当天(东八区不会退到前一天)',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')));
|
||||||
|
check('23:30 仍算当天',
|
||||||
|
DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08',
|
||||||
|
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')));
|
||||||
|
check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08');
|
||||||
|
check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === '');
|
||||||
|
|
||||||
|
echo "\n========================================\n";
|
||||||
|
echo "通过 {$pass} · 失败 {$fail}\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 配置落盘 / 鉴权死循环 回归测试
|
||||||
|
*
|
||||||
|
* 背景:登录页"登录成功"后立刻又报"未配置卷王登录账号"并反复跳回登录页。
|
||||||
|
* 根因是 ny_save_config 只写不校验(Db::settingsSet 内部吞异常且返回 void),
|
||||||
|
* data/ 目录不可写时上层以为保存成功,下一个请求读到的账号仍是空的。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1) ny_save_config 必须回读校验并真实反映落盘结果
|
||||||
|
* 2) ny_load_config 把 null 账号强制成空字符串(历史脏数据不再伪装成"未配置")
|
||||||
|
* 3) ny_apply_config_input:账号/密码不许被设置页误清空,连接信息才触发重连
|
||||||
|
* 4) ny_config_diag 能给出可自查的诊断字段
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 必须赶在 bootstrap 之前把数据目录重定向到临时目录,避免污染生产 data/ny.db
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/ny-cfgtest-' . getmypid() . '-' . bin2hex(random_bytes(3));
|
||||||
|
putenv('NY_DATA=' . $tmpDir);
|
||||||
|
$_ENV['NY_DATA'] = $tmpDir;
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
register_shutdown_function(static function () use ($tmpDir): void {
|
||||||
|
// 清理临时数据目录(Windows 下先删文件再删目录)
|
||||||
|
if (!is_dir($tmpDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (glob($tmpDir . '/*') ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $pass, $fail;
|
||||||
|
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
|
||||||
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n== 1) 保存后回读(模拟登录页写凭据) ==\n";
|
||||||
|
$cfg0 = ny_default_config();
|
||||||
|
$cfg0['username'] = 'teacher01';
|
||||||
|
$cfg0['password'] = 'P@ss w0rd';
|
||||||
|
$saved = ny_save_config($cfg0);
|
||||||
|
check('ny_save_config 返回 true', $saved === true, 'save_error=' . ny_config_save_error());
|
||||||
|
|
||||||
|
$back = ny_load_config();
|
||||||
|
check('回读账号一致', ($back['username'] ?? '') === 'teacher01', json_encode($back['username'] ?? null));
|
||||||
|
check('回读密码一致(含空格原样保留)', ($back['password'] ?? '') === 'P@ss w0rd', json_encode($back['password'] ?? null));
|
||||||
|
check('配置来源为 db 或 file', in_array(ny_config_source(), ['db', 'file'], true), ny_config_source());
|
||||||
|
check('无保存错误提示', ny_config_save_error() === '', ny_config_save_error());
|
||||||
|
|
||||||
|
echo "\n== 2) 脏数据兜底:账号被存成 null ==\n";
|
||||||
|
$dirty = $cfg0;
|
||||||
|
$dirty['username'] = null;
|
||||||
|
ny_write_json(ny_config_file(), $dirty);
|
||||||
|
// 强制走文件分支读取:把 DB 里的记录清掉
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
Db::settingsDelete('config');
|
||||||
|
}
|
||||||
|
$dirtyCfg = ny_load_config();
|
||||||
|
check('null 账号 → 空字符串而不是 null', $dirtyCfg['username'] === '', var_export($dirtyCfg['username'], true));
|
||||||
|
check('账号为字符串类型', is_string($dirtyCfg['username']));
|
||||||
|
|
||||||
|
echo "\n== 3) 设置保存:账号/密码不被误清空 ==\n";
|
||||||
|
$old = ny_default_config();
|
||||||
|
$old['username'] = 'admin';
|
||||||
|
$old['password'] = 'secret';
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => '']);
|
||||||
|
check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']);
|
||||||
|
check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => null]);
|
||||||
|
check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true));
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '******']);
|
||||||
|
check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => '']);
|
||||||
|
check('密码传空 → 不修改', $r['config']['password'] === 'secret');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['password' => 'newpwd']);
|
||||||
|
check('密码传新值 → 更新', $r['config']['password'] === 'newpwd');
|
||||||
|
check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['username' => 'admin2']);
|
||||||
|
check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]);
|
||||||
|
check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false);
|
||||||
|
check('满分已写入', (int)$r['config']['full_score'] === 90);
|
||||||
|
check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin');
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['refresh_interval' => 1]);
|
||||||
|
check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3);
|
||||||
|
|
||||||
|
$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']);
|
||||||
|
check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']);
|
||||||
|
|
||||||
|
echo "\n== 4) 落盘诊断 ==\n";
|
||||||
|
$d = ny_config_diag();
|
||||||
|
check('诊断含 storage', isset($d['storage']));
|
||||||
|
check('诊断含目录可写标记', array_key_exists('dir_writable', $d));
|
||||||
|
check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d));
|
||||||
|
check('诊断含数据库可写标记', array_key_exists('db_writable', $d));
|
||||||
|
check('诊断含是否已存账号', array_key_exists('has_username', $d));
|
||||||
|
check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
echo "\n== 5) 数据库写入回读校验 ==\n";
|
||||||
|
if (class_exists('Db') && Db::pdo() !== null) {
|
||||||
|
check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true);
|
||||||
|
check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello');
|
||||||
|
Db::settingsDelete('__ny_test_key');
|
||||||
|
check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x');
|
||||||
|
} else {
|
||||||
|
echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n----------------------------------------\n";
|
||||||
|
echo "通过 {$pass} 项,失败 {$fail} 项\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 多考试对比导出(成绩交叉矩阵)回归测试
|
||||||
|
*
|
||||||
|
* 覆盖 ny_exam_cell_score / ny_assemble_multi_exam_matrix:
|
||||||
|
* 1) 矩阵结构:第一行 = 考试名称,前两列 = 学号 / 姓名
|
||||||
|
* 2) 默认按学号升序
|
||||||
|
* 3) 最新成绩 = attempts_list 中最近一次「非暂存」提交;最高成绩 = 跨全部尝试的最高分
|
||||||
|
* 4) 草稿(temp_save)不计入"最新成绩",但计入"最高成绩"
|
||||||
|
* 5) 重考分数更低时,最新≠最高
|
||||||
|
* 6) 未考的考试列为空;学生并集(任一考试有提交即纳入)
|
||||||
|
* 7) 同一学号跨考试姓名以首个非空为准
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/../lib/multi_export.php';
|
||||||
|
|
||||||
|
$fail = 0;
|
||||||
|
$pass = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $fail, $pass;
|
||||||
|
if ($cond) {
|
||||||
|
$pass++;
|
||||||
|
echo " ✅ {$name}\n";
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
echo " ❌ {$name} {$detail}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一名学生的聚合行(与 DataService::aggregateByStudent 产出的结构保持一致)。
|
||||||
|
* 分数按时间升序给出(旧 -> 新),tempMarks 标记对应提交是否为草稿。
|
||||||
|
*/
|
||||||
|
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
|
||||||
|
foreach ($scoresAsc as $i => $sc) {
|
||||||
|
$t += 600;
|
||||||
|
$list[] = [
|
||||||
|
'idx' => $i + 1,
|
||||||
|
'submit_ts' => $t,
|
||||||
|
'score' => $sc,
|
||||||
|
'temp_save' => !empty($tempMarks[$i]),
|
||||||
|
'duration' => 120,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => $name,
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => end($scoresAsc),
|
||||||
|
'best_score' => $best ?? (float)max($scoresAsc),
|
||||||
|
'attempts' => count($scoresAsc),
|
||||||
|
'attempts_list'=> $list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 场景 ---------------- */
|
||||||
|
$examMath = [
|
||||||
|
'name' => '数学期中',
|
||||||
|
'rows' => [
|
||||||
|
// 张三:第一次 90,重考降到 80(最新≠最高);草稿 95 只算最高
|
||||||
|
stuRow('2021001', '张三', [90.0, 80.0], [false, false], 90.0),
|
||||||
|
stuRow('2021002', '李四', [70.0]),
|
||||||
|
// 草稿分:time1 真实 60,time2 草稿 99 → 最新应取 60,最高取 99
|
||||||
|
stuRow('2021004', '赵六', [60.0, 99.0], [false, true], 99.0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$examEnglish = [
|
||||||
|
'name' => '英语期中',
|
||||||
|
'rows' => [
|
||||||
|
stuRow('2021001', '张三', [85.0]),
|
||||||
|
stuRow('2021003', '王五', [60.0]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
echo "== 最新成绩矩阵 ==\n";
|
||||||
|
$m = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'latest');
|
||||||
|
check('表头第一列是学号', ($m['head'][0] ?? '') === '学号', json_encode($m['head']));
|
||||||
|
check('表头第二列是姓名', ($m['head'][1] ?? '') === '姓名');
|
||||||
|
check('表头第三列起是考试名称', ($m['head'][2] ?? '') === '数学期中' && ($m['head'][3] ?? '') === '英语期中', json_encode($m['head']));
|
||||||
|
check('考试列顺序保持选择顺序', $m['exam_order'] === ['math', 'eng']);
|
||||||
|
// 学号排序:2021001,2021002,2021003,2021004
|
||||||
|
// 注意:PHP 会把数字字符串键自动转成 int('2021001' -> 2021001),排序结果本身正确,
|
||||||
|
// 这里用 strval 归一后比较排序顺序(显示层 XlsxWriter 会再转回字符串,无影响)。
|
||||||
|
$sids = array_map('strval', array_column($m['rows'], 0));
|
||||||
|
check('按学号升序', $sids === ['2021001', '2021002', '2021003', '2021004'], json_encode($sids));
|
||||||
|
// 张三:数学最新=80(重考降分,跳过草稿),英语最新=85
|
||||||
|
$zhang = $m['rows'][0];
|
||||||
|
check('张三行:姓名正确', $zhang[1] === '张三');
|
||||||
|
check('张三-数学最新=80(重考降分)', (string)$zhang[2] === '80', (string)$zhang[2]);
|
||||||
|
check('张三-英语最新=85', (string)$zhang[3] === '85', (string)$zhang[3]);
|
||||||
|
// 李四只在数学:英语为空
|
||||||
|
$li = $m['rows'][1];
|
||||||
|
check('李四-数学=70', (string)$li[2] === '70', (string)$li[2]);
|
||||||
|
check('李四-英语为空', $li[3] === '', var_export($li[3], true));
|
||||||
|
// 王五只在英语:数学为空
|
||||||
|
$wang = $m['rows'][2];
|
||||||
|
check('王五-数学为空', $wang[2] === '', var_export($wang[2], true));
|
||||||
|
check('王五-英语=60', (string)$wang[3] === '60', (string)$wang[3]);
|
||||||
|
// 赵六:数学最新跳过草稿=60
|
||||||
|
$zhao = $m['rows'][3];
|
||||||
|
check('赵六-数学最新跳过草稿=60', (string)$zhao[2] === '60', (string)$zhao[2]);
|
||||||
|
|
||||||
|
echo "== 最高成绩矩阵 ==\n";
|
||||||
|
$mh = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'highest');
|
||||||
|
$zhangH = $mh['rows'][0];
|
||||||
|
check('张三-数学最高=90(含重考)', (string)$zhangH[2] === '90', (string)$zhangH[2]);
|
||||||
|
check('张三-英语最高=85', (string)$zhangH[3] === '85', (string)$zhangH[3]);
|
||||||
|
$zhaoH = $mh['rows'][3];
|
||||||
|
check('赵六-数学最高含草稿=99', (string)$zhaoH[2] === '99', (string)$zhaoH[2]);
|
||||||
|
|
||||||
|
echo "== 跨考试姓名 / 空考试 ==\n";
|
||||||
|
// 仅一场考试时仍可正常产出;姓名取首个非空
|
||||||
|
$single = ny_assemble_multi_exam_matrix(['math' => $examMath], 'latest');
|
||||||
|
check('单场考试表头正确', $single['head'] === ['学号', '姓名', '数学期中']);
|
||||||
|
check('单场考试行数=3', count($single['rows']) === 3, (string)count($single['rows']));
|
||||||
|
|
||||||
|
echo "== ny_exam_cell_score 单测 ==\n";
|
||||||
|
$r0 = stuRow('X', 'Y', [10.0, 20.0]); // latest=20, highest=20
|
||||||
|
check('latest=20', ny_exam_cell_score($r0, 'latest') === 20.0);
|
||||||
|
check('highest=20', ny_exam_cell_score($r0, 'highest') === 20.0);
|
||||||
|
$r1 = stuRow('X', 'Y', [20.0, 10.0]); // 重考降分
|
||||||
|
check('latest 取最近=10', ny_exam_cell_score($r1, 'latest') === 10.0);
|
||||||
|
check('highest 取最高=20', ny_exam_cell_score($r1, 'highest') === 20.0);
|
||||||
|
$r2 = stuRow('X', 'Y', [50.0, 88.0], [false, true]); // 草稿
|
||||||
|
check('latest 跳过草稿=50', ny_exam_cell_score($r2, 'latest') === 50.0);
|
||||||
|
check('highest 含草稿=88', ny_exam_cell_score($r2, 'highest') === 88.0);
|
||||||
|
$r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; // 无明细
|
||||||
|
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
|
||||||
|
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
|
||||||
|
|
||||||
|
/* ---------------- 「每次考试」块表(两行表头 / 合并单元格 / 未考) ---------------- */
|
||||||
|
/**
|
||||||
|
* 行内容比较:数据行是「参差不齐」的(某场次数少就没有尾巴上的格子),
|
||||||
|
* 短的一侧按空串补齐到等长再逐格比,避免因为"少几个空格子"误判。
|
||||||
|
*/
|
||||||
|
function rowEq(array $a, array $b): bool
|
||||||
|
{
|
||||||
|
$n = max(count($a), count($b));
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$x = isset($a[$i]) ? (string)$a[$i] : '';
|
||||||
|
$y = isset($b[$i]) ? (string)$b[$i] : '';
|
||||||
|
if ($x !== $y) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** merges 转成 "A2:A4" 形式,便于与期望值比较(不依赖生成顺序) */function mergeRefs(array $merges): array
|
||||||
|
{
|
||||||
|
$col = static function (int $n): string {
|
||||||
|
$s = '';
|
||||||
|
while ($n > 0) {
|
||||||
|
$m = ($n - 1) % 26;
|
||||||
|
$s = chr(65 + $m) . $s;
|
||||||
|
$n = (int)(($n - $m) / 26);
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
};
|
||||||
|
$out = [];
|
||||||
|
foreach ($merges as $m) {
|
||||||
|
$out[] = $col($m[1]) . $m[0] . ':' . $col($m[3]) . $m[2];
|
||||||
|
}
|
||||||
|
sort($out);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造带班级 / 组别的学生行 */
|
||||||
|
function stuRowCG(string $sid, string $name, string $cls, string $grp, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
|
||||||
|
{
|
||||||
|
$r = stuRow($sid, $name, $scoresAsc, $tempMarks, $best);
|
||||||
|
$r['cls'] = $cls;
|
||||||
|
$r['grp'] = $grp;
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版式场景完全对齐需求截图:张三 4 次数学 / 2 次英语;李四只有英语(数学「未考」)
|
||||||
|
$shotMath = ['name' => '数学', 'rows' => [
|
||||||
|
stuRow('356001', '张三', [112.0, 100.0, 136.0, 127.0], [], 136.0),
|
||||||
|
]];
|
||||||
|
$shotEng = ['name' => '英语', 'rows' => [
|
||||||
|
stuRow('356001', '张三', [112.0, 100.0], [], 112.0),
|
||||||
|
stuRow('356002', '李四', [130.0, 129.0, 133.0], [], 133.0),
|
||||||
|
]];
|
||||||
|
|
||||||
|
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
|
||||||
|
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
|
||||||
|
$rows = $bs['rows'];
|
||||||
|
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(每场 1 列)',
|
||||||
|
$rows[0] === ['学号', '姓名', '第几次', '数学', '英语'],
|
||||||
|
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('表头第 2 行:只有「成绩」小标题',
|
||||||
|
$rows[1] === ['', '', '', '成绩', '成绩'],
|
||||||
|
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('header_rows = 2', $bs['header_rows'] === 2);
|
||||||
|
/* 每场考试固定占 1 列(历史上每场占 2 列,第 2 列是遗留空位,
|
||||||
|
表现为"每场右边多出一根空白列");「第几次」逐行写各自次序号,不纵向合并
|
||||||
|
(合并会把 2、3 吞掉,只剩首行的 1)。 */
|
||||||
|
check('张三块首行:第几次=1,两场成绩(每场 1 列)',
|
||||||
|
rowEq($rows[2], ['356001', '张三', '1', '112', '112']),
|
||||||
|
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('张三第 2 行:第几次=2,数学 100 / 英语 100',
|
||||||
|
rowEq($rows[3], ['', '', '2', '100', '100']),
|
||||||
|
json_encode($rows[3], JSON_UNESCAPED_UNICODE));
|
||||||
|
/* 严格校验:3 个前导列 + 每场 1 列,所有行宽必须完全一致。
|
||||||
|
rowEq 会把短的一侧按空串补齐,宽松比较测不出「多一根空白列」这种回归。 */
|
||||||
|
check('每场考试只占 1 列:所有行宽一致 = 3 + 考试数',
|
||||||
|
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 5),
|
||||||
|
'列数序列 = ' . json_encode(array_map('count', $rows)));
|
||||||
|
check('张三第 3 行:第几次=3,数学 136 / 英语空',
|
||||||
|
$rows[4] === ['', '', '3', '136', ''],
|
||||||
|
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('张三第 4 行:第几次=4,数学 127 / 英语空',
|
||||||
|
$rows[5] === ['', '', '4', '127', ''],
|
||||||
|
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('李四块首行:数学未考 + 英语 130',
|
||||||
|
rowEq($rows[6], ['356002', '李四', '1', '未考', '130']),
|
||||||
|
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('李四第 2 / 3 行:第几次 2、3(未考列在合并区内留空)',
|
||||||
|
$rows[7] === ['', '', '2', '', '129'] && $rows[8] === ['', '', '3', '', '133'],
|
||||||
|
json_encode([$rows[7], $rows[8]], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('「第几次」逐行递增,不是被合并压成的一串 1',
|
||||||
|
array_map(static fn($r) => (string)$r[2], array_slice($rows, 2)) === ['1', '2', '3', '4', '1', '2', '3'],
|
||||||
|
json_encode(array_map(static fn($r) => $r[2], array_slice($rows, 2)), JSON_UNESCAPED_UNICODE));
|
||||||
|
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
|
||||||
|
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
|
||||||
|
$bs['total_rows'] . '/' . $bs['students']);
|
||||||
|
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
|
||||||
|
|
||||||
|
$refs = mergeRefs($bs['merges']);
|
||||||
|
check('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
|
||||||
|
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
|
||||||
|
json_encode($refs));
|
||||||
|
check('「第几次」列没有任何纵向合并(表头 C1:C2 除外)',
|
||||||
|
!array_filter($bs['merges'], static fn($m) => (int)$m[0] > 1 && (int)$m[1] === 3 && (int)$m[3] === 3),
|
||||||
|
json_encode($bs['merges']));
|
||||||
|
check('表头第 1 行没有任何横向合并(考试名各占 1 列)',
|
||||||
|
!array_filter($bs['merges'], static fn($m) => (int)$m[0] === 1 && (int)$m[2] === 1 && $m[1] !== $m[3]),
|
||||||
|
json_encode($bs['merges']));
|
||||||
|
/* 列宽够用就好:考试列要装得下表头考试名(中文按 2 个半角宽算)。 */
|
||||||
|
check('列宽 = 学号 10 / 姓名 9 / 第几次 10 / 每场考试 1 列(按考试名估宽)',
|
||||||
|
$bs['widths'] === [10, 9, 10, 8, 8], json_encode($bs['widths']));
|
||||||
|
|
||||||
|
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
|
||||||
|
// 合格线 110:
|
||||||
|
// 张三数学 [112,100,136,127] → 112/136/127 合格 = 3
|
||||||
|
// 张三英语 [112,100] → 112 合格 = 1
|
||||||
|
// 李四英语 [130,129,133] → 130/129/133 合格 = 3
|
||||||
|
$bp = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110]);
|
||||||
|
$pp = $bp['rows'];
|
||||||
|
check('启用合格行后 header_rows 仍为 2', $bp['header_rows'] === 2);
|
||||||
|
check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pass_line'] === 110.0,
|
||||||
|
var_export($bp['pass_line'], true));
|
||||||
|
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
|
||||||
|
check('张三合格次数行:数学 3 次 / 英语 1 次',
|
||||||
|
$pp[6] === ['', '', '合格次数', '3', '1'],
|
||||||
|
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
|
||||||
|
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
|
||||||
|
check('李四合格次数行:数学未考留空 / 英语 3 次',
|
||||||
|
$pp[10] === ['', '', '合格次数', '', '3'],
|
||||||
|
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
|
||||||
|
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
|
||||||
|
|
||||||
|
$pm = $bp['cell_styles'];
|
||||||
|
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
|
||||||
|
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
|
||||||
|
// 张三英语最高分 112 在第 3 行第 5 列 → 红字
|
||||||
|
check('张三英语最高分 112 标红', ($pm['3,5'] ?? '') === 'red', json_encode($pm));
|
||||||
|
// 李四英语最高分 133 在第 10 行第 5 列 → 红字
|
||||||
|
check('李四英语最高分 133 标红', ($pm['10,5'] ?? '') === 'red', json_encode($pm));
|
||||||
|
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
|
||||||
|
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
|
||||||
|
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
|
||||||
|
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
|
||||||
|
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
|
||||||
|
check('李四英语合格次数达标绿底', ($pm['11,5'] ?? '') === 'green', json_encode($pm));
|
||||||
|
|
||||||
|
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
|
||||||
|
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
|
||||||
|
$hp = $bh['rows'];
|
||||||
|
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
|
||||||
|
$hp[6] === ['', '', '合格次数', '3', '1'],
|
||||||
|
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('阈值 4 次时不达标不标绿',
|
||||||
|
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
|
||||||
|
json_encode($bh['cell_styles']));
|
||||||
|
|
||||||
|
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
|
||||||
|
// 赵六:数学第 2 次是草稿 → 默认跳过(不占编号)
|
||||||
|
$draftMath = ['name' => '数学', 'rows' => [
|
||||||
|
stuRow('D1', '赵六', [60.0, 99.0], [false, true], 99.0),
|
||||||
|
]];
|
||||||
|
$ds = ny_assemble_block_sheet(['m' => $draftMath]);
|
||||||
|
check('默认跳过草稿:只剩 1 次',
|
||||||
|
rowEq($ds['rows'][2], ['D1', '赵六', '1', '60']),
|
||||||
|
json_encode($ds['rows'][2], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('跳过草稿后无多余行', count($ds['rows']) === 3, (string)count($ds['rows']));
|
||||||
|
|
||||||
|
$di = ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false]);
|
||||||
|
check('保留草稿:2 次且草稿分参与',
|
||||||
|
rowEq($di['rows'][3], ['', '', '2', '99']),
|
||||||
|
json_encode($di['rows'][3], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
// 只有草稿的学生:跳过草稿后整场无有效成绩 → 不出现在表里
|
||||||
|
$onlyDraft = ['name' => '数学', 'rows' => [
|
||||||
|
stuRow('X1', '只交草稿', [88.0], [true], 88.0),
|
||||||
|
stuRow('X2', '正常', [70.0], [], 70.0),
|
||||||
|
]];
|
||||||
|
$od = ny_assemble_block_sheet(['m' => $onlyDraft]);
|
||||||
|
check('全草稿学生被剔除(只剩正常那位)', $od['students'] === 1, (string)$od['students']);
|
||||||
|
check('全草稿学生未写「未考」占位', rowEq($od['rows'][2], ['X2', '正常', '1', '70']),
|
||||||
|
json_encode($od['rows'][2], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
// 多班级 / 多组别:按 班级 → 组别 → 学号 排序,连续相同班级 / 组别合并
|
||||||
|
$multi = ['name' => '数学', 'rows' => [
|
||||||
|
stuRowCG('S3', '王五', '1班', 'B', [60.0]),
|
||||||
|
stuRowCG('S4', '赵六', '2班', 'B', [50.0]),
|
||||||
|
stuRowCG('S1', '张三', '1班', 'A', [90.0, 80.0]),
|
||||||
|
stuRowCG('S2', '李四', '1班', 'A', [70.0]),
|
||||||
|
]];
|
||||||
|
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
|
||||||
|
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
|
||||||
|
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学'],
|
||||||
|
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('按 班级 → 组别 → 学号 排序',
|
||||||
|
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
|
||||||
|
=== ['S1', 'S2', 'S3', 'S4'],
|
||||||
|
json_encode([$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]));
|
||||||
|
check('班级 / 组别只写在学生块首行',
|
||||||
|
rowEq($mc['rows'][2], ['1班', 'A', 'S1', '张三', '1', '90']),
|
||||||
|
json_encode($mc['rows'][2], JSON_UNESCAPED_UNICODE));
|
||||||
|
$mcRefs = mergeRefs($mc['merges']);
|
||||||
|
check('相同班级 / 相同组别的单元格合并(1班 S1~S3 归一格,A 组 S1~S2 归一格)',
|
||||||
|
in_array('A3:A6', $mcRefs, true) && in_array('B3:B5', $mcRefs, true),
|
||||||
|
json_encode($mcRefs));
|
||||||
|
check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
|
||||||
|
in_array('C3:C4', $mcRefs, true) && in_array('D3:D4', $mcRefs, true),
|
||||||
|
json_encode($mcRefs));
|
||||||
|
|
||||||
|
// 空考试:只出两行表头
|
||||||
|
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
|
||||||
|
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
|
||||||
|
check('空考试仅剩表头合并(考试名不再横向合并)',
|
||||||
|
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2'],
|
||||||
|
json_encode(mergeRefs($bEmpty['merges'])));
|
||||||
|
|
||||||
|
// 考试列顺序跟随传入顺序
|
||||||
|
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
|
||||||
|
check('考试列顺序跟随传入顺序(英语在前)',
|
||||||
|
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
|
||||||
|
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
|
||||||
|
$w = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110]);
|
||||||
|
check('表头 = 学号/姓名/第1次…第N次/合格次数',
|
||||||
|
$w['rows'][0] === ['学号', '姓名', '第1次', '第2次', '第3次', '合格次数'],
|
||||||
|
json_encode($w['rows'][0], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('每人一行,共 2 人', count($w['rows']) === 3 && $w['students'] === 2, (string)count($w['rows']));
|
||||||
|
// 张三英语 [112,100]:只有 2 次,第 3 列留空;≥110 → 112 合格 = 1
|
||||||
|
check('张三行:112 / 100 / 空,合格 1 次',
|
||||||
|
$w['rows'][1] === ['356001', '张三', '112', '100', '', '1'],
|
||||||
|
json_encode($w['rows'][1], JSON_UNESCAPED_UNICODE));
|
||||||
|
// 李四英语 [130,129,133]:全 ≥110 = 3 次合格
|
||||||
|
check('李四行:130 / 129 / 133,合格 3 次',
|
||||||
|
$w['rows'][2] === ['356002', '李四', '130', '129', '133', '3'],
|
||||||
|
json_encode($w['rows'][2], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('attempt_cols=3', $w['attempt_cols'] === 3, (string)$w['attempt_cols']);
|
||||||
|
check('最高分 112(张三第 3 列)标红', ($w['cell_styles']['2,3'] ?? '') === 'red', json_encode($w['cell_styles']));
|
||||||
|
check('最高分 133(李四第 5 列)标红', ($w['cell_styles']['3,5'] ?? '') === 'red', json_encode($w['cell_styles']));
|
||||||
|
check('张三合格次数 1 次达标绿底', ($w['cell_styles']['2,6'] ?? '') === 'green', json_encode($w['cell_styles']));
|
||||||
|
check('李四合格次数 3 次达标绿底', ($w['cell_styles']['3,6'] ?? '') === 'green', json_encode($w['cell_styles']));
|
||||||
|
|
||||||
|
// 阈值 4 次 → 都不到阈值,仍显示实际次数(1 / 3),但不标绿
|
||||||
|
$w4 = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'pass_times' => 4]);
|
||||||
|
check('阈值 4 次时仍显示实际合格次数(不隐藏)',
|
||||||
|
$w4['rows'][1][5] === '1' && $w4['rows'][2][5] === '3',
|
||||||
|
json_encode([$w4['rows'][1][5], $w4['rows'][2][5]], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('阈值 4 次时不标绿',
|
||||||
|
!isset($w4['cell_styles']['2,6']) && !isset($w4['cell_styles']['3,6']),
|
||||||
|
json_encode($w4['cell_styles']));
|
||||||
|
|
||||||
|
// 关闭标红 / 关掉合格行
|
||||||
|
$wOff = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'mark_best' => false, 'pass_row' => false]);
|
||||||
|
check('mark_best=false 不标红', $wOff['cell_styles'] === [], json_encode($wOff['cell_styles']));
|
||||||
|
check('pass_row=false 表头无合格次数', !in_array('合格次数', $wOff['rows'][0], true),
|
||||||
|
json_encode($wOff['rows'][0], JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
/** 检查合并区间两两不重叠(区间重叠会让 Excel 打开时报"需要修复") */
|
||||||
|
function overlapsOf(array $merges): array
|
||||||
|
{
|
||||||
|
$bad = [];
|
||||||
|
$n = count($merges);
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
for ($j = $i + 1; $j < $n; $j++) {
|
||||||
|
$a = $merges[$i];
|
||||||
|
$b = $merges[$j];
|
||||||
|
if ($a[0] <= $b[2] && $b[0] <= $a[2] && $a[1] <= $b[3] && $b[1] <= $a[3]) {
|
||||||
|
$bad[] = json_encode($a) . '×' . json_encode($b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== 班级 / 组别列的显示规则 ==\n";
|
||||||
|
$f1 = ny_multi_col_flags(['1班'], [], ['1班'], ['A', 'B']);
|
||||||
|
check('只选 1 个班级且未选组别 → 班级 / 组别列都不出',
|
||||||
|
$f1['with_cls'] === false && $f1['with_grp'] === false, json_encode($f1));
|
||||||
|
$f2 = ny_multi_col_flags(['1班', '2班'], ['A', 'B'], ['1班', '2班'], ['A', 'B']);
|
||||||
|
check('选了多班级 + 多组别 → 两列都出',
|
||||||
|
$f2['with_cls'] === true && $f2['with_grp'] === true, json_encode($f2));
|
||||||
|
$f3 = ny_multi_col_flags(['1班'], ['A', 'B'], ['1班'], ['A', 'B']);
|
||||||
|
check('只选 1 个班级但选了多组别 → 只出组别列',
|
||||||
|
$f3['with_cls'] === false && $f3['with_grp'] === true, json_encode($f3));
|
||||||
|
$f4 = ny_multi_col_flags([], [], ['1班'], ['A']);
|
||||||
|
check('未筛选且数据只有 1 个班 / 1 个组 → 两列都不出',
|
||||||
|
$f4['with_cls'] === false && $f4['with_grp'] === false, json_encode($f4));
|
||||||
|
$f5 = ny_multi_col_flags([], [], ['1班', '2班', '3班'], ['A', 'B']);
|
||||||
|
check('未筛选且数据跨 3 个班 → 按数据补出班级 / 组别列',
|
||||||
|
$f5['with_cls'] === true && $f5['with_grp'] === true, json_encode($f5));
|
||||||
|
$f6 = ny_multi_col_flags(['1班', '2班'], ['A'], ['1班', '2班'], ['A']);
|
||||||
|
check('多班级 + 只选 1 个组别 → 只出班级列',
|
||||||
|
$f6['with_cls'] === true && $f6['with_grp'] === false, json_encode($f6));
|
||||||
|
|
||||||
|
echo "== 合并区间不重叠(防 Excel 报修复) ==\n";
|
||||||
|
check('截图版式:无重叠', overlapsOf($bs['merges']) === [], json_encode(overlapsOf($bs['merges'])));
|
||||||
|
check('多班级多组别:无重叠', overlapsOf($mc['merges']) === [], json_encode(overlapsOf($mc['merges'])));
|
||||||
|
check('单场导出(带班级组别列):无重叠',
|
||||||
|
overlapsOf(ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true])['merges']) === []);
|
||||||
|
check('草稿模式:无重叠',
|
||||||
|
overlapsOf(ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false])['merges']) === []);
|
||||||
|
|
||||||
|
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 新功能回归测试:
|
||||||
|
* 1) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
|
||||||
|
* 2) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
|
||||||
|
* 3) 名单导入列识别 rowsToStudents(表头识别 / 去重 / Excel 数字尾巴)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
$pass = 0;
|
||||||
|
$fail = 0;
|
||||||
|
function check(string $name, bool $cond, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $pass, $fail;
|
||||||
|
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
|
||||||
|
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 3) 排序:再次提交置顶 ============ */
|
||||||
|
echo "\n== 排序(last_submit_ts 置顶)==\n";
|
||||||
|
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
|
||||||
|
$aggRows = [
|
||||||
|
['sid' => 'S01', 'name' => '甲', 'score' => 98.0, 'submit_ts' => 1000, 'last_submit_ts' => 5000, 'last_submit_at' => '2026-09-10 12:00:00'],
|
||||||
|
['sid' => 'S02', 'name' => '乙', 'score' => 90.0, 'submit_ts' => 2000, 'last_submit_ts' => 2000, 'last_submit_at' => '2026-09-10 10:00:00'],
|
||||||
|
];
|
||||||
|
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
|
||||||
|
usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
|
||||||
|
$d = $sortTs($y) <=> $sortTs($x);
|
||||||
|
return $d !== 0 ? $d : strcmp((string)($x['sid'] ?? ''), (string)($y['sid'] ?? ''));
|
||||||
|
});
|
||||||
|
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
/* ============ 2) XlsxWriter 多工作表 ============ */
|
||||||
|
echo "\n== XlsxWriter 多工作表 ==\n";
|
||||||
|
$sheets = [
|
||||||
|
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
|
||||||
|
['name' => '2班', 'rows' => [['学号', '姓名'], ['S02', '乙']], 'widths' => [16, 14]],
|
||||||
|
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S03', 90]], 'widths' => [16, 11]],
|
||||||
|
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S04', 80]], 'widths' => [16, 11]], // 重名 → 应去重
|
||||||
|
['name' => str_repeat('长', 40), 'rows' => [['X'], [1]], 'widths' => [10]], // 超长名 → 截断
|
||||||
|
];
|
||||||
|
$bin = XlsxWriter::renderMulti($sheets);
|
||||||
|
check('renderMulti 产出 PK ZIP', substr($bin, 0, 2) === 'PK');
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
if (class_exists('ZipArchive')) {
|
||||||
|
$tmp = __DIR__ . '/_multi.xlsx';
|
||||||
|
file_put_contents($tmp, $bin);
|
||||||
|
$z = new ZipArchive();
|
||||||
|
if ($z->open($tmp) === true) {
|
||||||
|
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||||
|
$n = $z->getNameIndex($i);
|
||||||
|
if (strpos($n, 'xl/worksheets/sheet') === 0) {
|
||||||
|
$names[] = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$z->close();
|
||||||
|
}
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
|
||||||
|
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
|
||||||
|
|
||||||
|
/* ============ 3) 名单导入列识别 ============ */
|
||||||
|
echo "\n== 名单导入列识别 ==\n";
|
||||||
|
$meta = [];
|
||||||
|
$parsed = Roster::rowsToStudents([
|
||||||
|
['学号', '姓名', '班级', '组别', '登陆账号'],
|
||||||
|
['2024012001.0', '张三', '1班', 'A', 'stu001'],
|
||||||
|
['2024012002', '李四', '1班', 'B', 'stu002'],
|
||||||
|
['2024012001', '张三重复', '1班', 'A', 'stu001'], // 学号重复 → 跳过
|
||||||
|
['', ''], // 空行 → 跳过
|
||||||
|
], $meta);
|
||||||
|
check('识别到 2 人(重复学号与空行被跳过)', count($parsed) === 2, json_encode($parsed, JSON_UNESCAPED_UNICODE));
|
||||||
|
check('学号去掉 Excel 数字尾巴 .0', ($parsed[0]['sid'] ?? '') === '2024012001', (string)($parsed[0]['sid'] ?? ''));
|
||||||
|
check('识别班级 / 组别 / 登陆账号列',
|
||||||
|
($parsed[0]['cls'] ?? '') === '1班' && ($parsed[0]['grp'] ?? '') === 'A' && ($parsed[0]['login'] ?? '') === 'stu001',
|
||||||
|
json_encode($parsed[0] ?? [], JSON_UNESCAPED_UNICODE));
|
||||||
|
check('重复学号记入 duplicates', (int)($meta['duplicates'] ?? 0) === 1, (string)($meta['duplicates'] ?? ''));
|
||||||
|
check('空行记入 skipped', (int)($meta['skipped'] ?? 0) === 1, (string)($meta['skipped'] ?? ''));
|
||||||
|
|
||||||
|
echo "\n========================================\n";
|
||||||
|
echo "通过 {$pass} · 失败 {$fail}\n";
|
||||||
|
exit($fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 文档站本地预览服务
|
||||||
|
*
|
||||||
|
* 站点是按 /ny-exam-platform 子路径构建的(base: '/ny-exam-platform/'),
|
||||||
|
* 直接把 dist 目录当根来跑静态服务会导致所有资源 404。
|
||||||
|
* 这里做一层前缀重写:把 /ny-exam-platform/<path> 映射到 dist 目录下的 <path>,
|
||||||
|
* 根路径 / 自动跳转到 /ny-exam-platform/。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node tools/docs-preview.js [端口] [dist目录]
|
||||||
|
* node tools/docs-preview.js 5199 docs/.vitepress/dist-final
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = parseInt(process.argv[2], 10) || 5199;
|
||||||
|
const DIST = path.resolve(process.argv[3] || path.join(__dirname, '..', 'docs', '.vitepress', 'dist-final'));
|
||||||
|
const BASE = (process.env.DOCS_BASE || '/ny-exam-platform').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fs.existsSync(DIST)) {
|
||||||
|
console.error('[docs-preview] 找不到构建产物:' + DIST);
|
||||||
|
console.error('请先构建:DOCS_OUT=.vitepress/dist-final npx vitepress build .');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 URL 中的前缀剥掉,得到 dist 内的相对路径 */
|
||||||
|
function toRel(urlPath) {
|
||||||
|
let p = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
|
||||||
|
if (p === BASE || p === BASE + '/') return '/index.html';
|
||||||
|
if (p.startsWith(BASE + '/')) p = p.slice(BASE.length);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次尝试:原样 / .html / 目录下 index.html,模拟静态托管的行为 */
|
||||||
|
function resolveFile(rel) {
|
||||||
|
const clean = path.normalize(rel).replace(/^([/\\])+/, '');
|
||||||
|
const full = path.join(DIST, clean);
|
||||||
|
if (full.indexOf(DIST) !== 0) return null; // 防目录穿越
|
||||||
|
const candidates = [full, full + '.html', path.join(full, 'index.html')];
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const urlPath = req.url || '/';
|
||||||
|
|
||||||
|
// 根路径 / 直接跳到子路径首页
|
||||||
|
if (urlPath === '/' || urlPath === '') {
|
||||||
|
res.writeHead(302, { Location: BASE + '/' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = toRel(urlPath);
|
||||||
|
const file = resolveFile(rel);
|
||||||
|
if (!file) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('404 Not Found: ' + urlPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log('[docs-preview] 文档站已启动');
|
||||||
|
console.log(' 访问地址:http://127.0.0.1:' + PORT + BASE + '/');
|
||||||
|
console.log(' 构建目录:' + DIST);
|
||||||
|
});
|
||||||
|
server.on('error', e => {
|
||||||
|
console.error('[docs-preview] 启动失败:' + e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 修复 / 重置系统连接配置(终端一次性工具)
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 连接配置同时存在于 data/ny.db(SQLite,加载时优先)与 data/config.json(文件备份)。
|
||||||
|
* ny_load_config() 以 DB 为准,因此一旦部署实例的 ny.db 被 e2e 测试串味或误改写成
|
||||||
|
* 127.0.0.1 / mock 地址,登录页就会显示「系统连接信息已配置(127.0.0.1/api)」且连不上真实卷王。
|
||||||
|
* 本工具通过 ny_save_config 双写,一次性把两处都纠正回正确的生产地址。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* php tools/fix-config.php [--base-url=https://exam.nxxy335.top] [--api-prefix=/api] [--keep-creds]
|
||||||
|
*
|
||||||
|
* 默认行为:
|
||||||
|
* - base_url 修正为 https://exam.nxxy335.top(可用 --base-url 覆盖)
|
||||||
|
* - api_prefix 修正为 /api
|
||||||
|
* - 账号/密码清空(绝不臆造真实卷王凭据),需在登录页重新输入真实账号
|
||||||
|
* - 加 --keep-creds 则保留现有账号密码不动
|
||||||
|
*
|
||||||
|
* 测试隔离:可用 NY_DATA=/tmp/xxx 指向临时目录,不影响生产库。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
$args = array_slice($argv, 1);
|
||||||
|
$baseUrl = 'https://exam.nxxy335.top';
|
||||||
|
$apiPrefix = '/api';
|
||||||
|
$keepCreds = false;
|
||||||
|
foreach ($args as $a) {
|
||||||
|
if (strncmp($a, '--base-url=', 11) === 0) {
|
||||||
|
$baseUrl = substr($a, 11);
|
||||||
|
} elseif (strncmp($a, '--api-prefix=', 13) === 0) {
|
||||||
|
$apiPrefix = substr($a, 13);
|
||||||
|
} elseif ($a === '--keep-creds') {
|
||||||
|
$keepCreds = true;
|
||||||
|
} elseif ($a === '--help' || $a === '-h') {
|
||||||
|
echo "用法: php tools/fix-config.php [--base-url=URL] [--api-prefix=/api] [--keep-creds]\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = ny_load_config();
|
||||||
|
$before = (string)($cfg['base_url'] ?? '');
|
||||||
|
$cfg['base_url'] = rtrim(trim($baseUrl), '/');
|
||||||
|
$cfg['api_prefix'] = '/' . ltrim(trim($apiPrefix), '/');
|
||||||
|
if (!$keepCreds) {
|
||||||
|
$cfg['username'] = '';
|
||||||
|
$cfg['password'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = ny_save_config($cfg);
|
||||||
|
if (!$ok) {
|
||||||
|
fwrite(STDERR, "保存失败:DB 与文件均不可写?请检查 " . NY_DATA . " 目录权限。\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string) parse_url($cfg['base_url'], PHP_URL_HOST);
|
||||||
|
echo "修复前 base_url = " . ($before === '' ? '(空)' : $before) . "\n";
|
||||||
|
echo "修复后 base_url = " . $cfg['base_url'] . "\n";
|
||||||
|
echo "api_prefix = " . $cfg['api_prefix'] . "\n";
|
||||||
|
echo "登录页将显示 = 系统连接信息已配置(" . $host . $cfg['api_prefix'] . ")\n";
|
||||||
|
echo "账号/密码 = " . ($keepCreds ? '保持不变' : '已清空(请在登录页重新输入真实卷王账号)') . "\n";
|
||||||
|
echo "配置来源 = " . ny_config_source() . "\n";
|
||||||
|
echo "OK\n";
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 生成数据大屏首页的可打开预览:docs/samples/dash-home-preview.html
|
||||||
|
*
|
||||||
|
* 关键点与 make-samples.js 一致——卡片**直接调用 dashboard.php 里生产环境的 clsCardHtml()**,
|
||||||
|
* 不是另写一份渲染逻辑,所以预览长什么样,线上首页就长什么样,不会两边漂移。
|
||||||
|
* 数据取自本机 mock-server(tools/mock-server.js)+ PHP 内置服务器,名单与答卷都是真跑出来的。
|
||||||
|
*
|
||||||
|
* 用法:node tools/make-dash-sample.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const PORT = 18095;
|
||||||
|
const INI = process.env.PHP_INI || '';
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
|
||||||
|
/* ---------- 临时数据目录:绝不动项目 data/ ---------- */
|
||||||
|
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-sample-' + process.pid);
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
const DATA = path.resolve(p);
|
||||||
|
process.env.NY_DATA = DATA;
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:19911',
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: '', password: '',
|
||||||
|
verify_ssl: false, timeout: 10,
|
||||||
|
refresh_interval: 15, page_size: 50,
|
||||||
|
dedupe: true, include_temp_save: false,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100, layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
/* 名单:mock 的「卷内参与者」只放行 90001-90005,应考名单会被收敛到这几人,
|
||||||
|
所以把 5 人分散到 4 个班,其余人做无答卷的填充(让卡片同时能看到缺考数)。 */
|
||||||
|
const STUDENTS = [];
|
||||||
|
[['90001', '高一(1)班'], ['90002', '高一(2)班'], ['90003', '高一(3)班'],
|
||||||
|
['90004', '高一(4)班'], ['90005', '高一(1)班']].forEach(([sid, cls]) =>
|
||||||
|
STUDENTS.push({ sid, name: sid, cls, grp: 'A组', login: '' }));
|
||||||
|
['高一(1)班', '高一(2)班', '高一(3)班', '高一(4)班'].forEach((cls, ci) => {
|
||||||
|
for (let i = 1; i <= 11; i++) {
|
||||||
|
const sid = String(9200 + ci * 100 + i);
|
||||||
|
STUDENTS.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify({ students: STUDENTS, updated_at: '2026-01-01 00:00:00' }));
|
||||||
|
|
||||||
|
let mock = null, srv = null, cleanedUp = false;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
try { srv && srv.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { mock && mock.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
|
||||||
|
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
|
||||||
|
|
||||||
|
/* ---------- 极简 HTTP ---------- */
|
||||||
|
const jar = [];
|
||||||
|
function collect(res) {
|
||||||
|
for (const sc of (res.headers['set-cookie'] || [])) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const i = jar.findIndex(x => x.startsWith(pair.split('=')[0] + '='));
|
||||||
|
if (i >= 0) jar[i] = pair; else jar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = () => (jar.length ? jar.join('; ') : '');
|
||||||
|
function req(method, p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = { 'Accept': 'application/json' };
|
||||||
|
if (cookie()) h['Cookie'] = cookie();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 登录必须走表单编码(login.php 读 $_POST),只发一个空 POST 是登不上的
|
||||||
|
function formPost(p, fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = new URLSearchParams(fields).toString();
|
||||||
|
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) };
|
||||||
|
if (cookie()) h['Cookie'] = cookie();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => { collect(res); resolve({ raw: Buffer.concat(bufs).toString('utf8') }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(body); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ---------- 抽出生产环境的卡片渲染函数 ---------- */
|
||||||
|
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
|
||||||
|
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
|
||||||
|
if (!m) { console.error('未能从 dashboard.php 抽取 clsCardHtml'); cleanup(); process.exit(1); }
|
||||||
|
const esc = s => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
|
||||||
|
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
|
||||||
|
const rateColor = r => (r >= 90 ? 'var(--ok)' : (r >= 60 ? 'var(--warn)' : 'var(--danger)'));
|
||||||
|
const clsCardHtml = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await delay(2200);
|
||||||
|
await formPost('/login.php', { username: 'mock', password: 'surveyking' }); // 顺带把账密写进临时配置
|
||||||
|
const pr = await req('GET', '/api.php?action=projects');
|
||||||
|
const projects = ((JSON.parse(pr.raw).data) || []).filter(x => x.id && x.id !== 'p2');
|
||||||
|
if (!projects.length) { console.error('mock 没返回可用问卷,原始响应:\n' + pr.raw.slice(0, 800)); cleanup(); process.exit(1); }
|
||||||
|
const project = projects[0];
|
||||||
|
|
||||||
|
const dr = await req('GET', '/api.php?action=dashboard&projectId=' + encodeURIComponent(project.id));
|
||||||
|
const data = JSON.parse(dr.raw).data || {};
|
||||||
|
const classes = data.classes || [];
|
||||||
|
if (!classes.length) { console.error('dashboard 没返回班级,名单是否为空?'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
const cards = classes.map(clsCardHtml).join('');
|
||||||
|
const projectName = project.name || project.id;
|
||||||
|
const html = [
|
||||||
|
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
|
||||||
|
'<title>数据大屏首页(预览)</title>',
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||||
|
'<link rel="stylesheet" href="../../assets/style.css">',
|
||||||
|
'<style>',
|
||||||
|
'/* 预览页专属:补上 hero 里样式类所需的小件(原页面由 dashboard.php 内联给出) */',
|
||||||
|
'.brand-text{display:flex;flex-direction:column;gap:2px}',
|
||||||
|
'.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border-2);vertical-align:1px}',
|
||||||
|
'.dot.on{background:var(--ok)}',
|
||||||
|
'.dot.stale{background:var(--danger)}',
|
||||||
|
'.spacer{flex:1}',
|
||||||
|
'.preview-tip{margin:12px 0 0;padding:8px 12px;border:1px dashed var(--border);border-radius:var(--radius);',
|
||||||
|
' font-size:12px;color:var(--text-3);background:var(--panel-weak,#f7f8fc)}',
|
||||||
|
'</style></head>',
|
||||||
|
'<body class="dash dash-home">',
|
||||||
|
'<div class="wrap">',
|
||||||
|
' <div class="dash-hero">',
|
||||||
|
' <div class="brand"><h1 class="brand-title">NY问卷考试系统数据平台</h1></div>',
|
||||||
|
' <div class="dash-controls">',
|
||||||
|
' <select id="projectSel"><option value="' + esc(project.id) + '">' + esc(project.name) +
|
||||||
|
'(' + project.total + ' 份)</option></select>',
|
||||||
|
' <span id="classSelWrap"><select id="classSel" disabled><option value="">请先选问卷</option></select></span>',
|
||||||
|
' <label class="switch"><input type="checkbox" id="autoRefresh" checked><span></span></label>',
|
||||||
|
' <select id="intervalSel"><option value="15" selected>15 秒</option></select>',
|
||||||
|
' <button class="btn" id="btnRefresh">刷新</button>',
|
||||||
|
' <button class="btn" id="btnFullscreen">⛶ 全屏</button>',
|
||||||
|
' <a class="btn back" id="linkIndex" href="index.php">← 主系统</a>',
|
||||||
|
' <a class="btn back" id="linkLogout" href="logout.php">退出</a>',
|
||||||
|
' </div>',
|
||||||
|
' </div>',
|
||||||
|
' <div style="margin-top:14px;font-size:12px;color:var(--text-3);display:flex;align-items:center;gap:6px;flex-wrap:wrap;">',
|
||||||
|
' <span id="heroSub">共 ' + classes.length + ' 个班级 · 点击卡片进入该班大屏</span>',
|
||||||
|
' <span class="spacer"></span>',
|
||||||
|
' 最后更新:<span id="lastUpdate">' + esc(data.updated_at || '-') + '</span><i id="liveDot" class="dot on"></i>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="preview-tip">静态预览:这张页面由生产环境 dashboard.php 的 <code>clsCardHtml()</code> 渲染,',
|
||||||
|
' 数据取自 mock 卷王。真实站点上点卡片会整页跳转到 <code>dashboard.php?pid=…&class=…</code>,',
|
||||||
|
' 进入该班独立大屏(那里不再有班级选择、返回主系统与退出,浏览器后退键也被锁住)。</div>',
|
||||||
|
' <div id="board">',
|
||||||
|
' <section class="cards" id="boardSummary">',
|
||||||
|
' <div class="card accent-blue"><span>总人数</span><b>0</b></div>',
|
||||||
|
' <div class="card ok accent-green"><span>已提交</span><b>0</b></div>',
|
||||||
|
' <div class="card warn accent-orange"><span>缺考</span><b>0</b></div>',
|
||||||
|
' <div class="card accent-teal"><span>平均分</span><b>-</b></div>',
|
||||||
|
' <div class="card accent-indigo"><span>合格率</span><b>-</b></div>',
|
||||||
|
' </section>',
|
||||||
|
' <div class="sec-title" id="boardTitle">班级入口 <span class="tag-gray">点击卡片进入该班独立大屏</span></div>',
|
||||||
|
' <div id="boardBody"><div class="cls-grid">' + cards + '</div></div>',
|
||||||
|
' </div>',
|
||||||
|
'</div>',
|
||||||
|
'<script>/* 预览页:把"全屏/刷新"按钮做成 plainly 不可点,仅示意版式 */',
|
||||||
|
'document.addEventListener(\'DOMContentLoaded\', function () {',
|
||||||
|
' ["#btnFullscreen", "#btnRefresh", "#projectSel", "#classSelWrap"].forEach(function (s) {',
|
||||||
|
' var el = document.querySelector(s); if (el) el.setAttribute("disabled", "disabled");',
|
||||||
|
' });',
|
||||||
|
'});</script>',
|
||||||
|
'</body></html>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const out = path.join(ROOT, 'docs', 'samples', 'dash-home-preview.html');
|
||||||
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||||
|
fs.writeFileSync(out, html, 'utf8');
|
||||||
|
console.log('已生成 ' + path.relative(ROOT, out));
|
||||||
|
console.log(' 问卷:' + projectName + ' 班级:' + classes.map(c => c.class).join('、'));
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 把 tools/make-samples.php 产出的数据渲染成可打开的样例页面。
|
||||||
|
*
|
||||||
|
* 关键点:页面里的表格**直接调用 assets/app.js 里生产环境的 sheetToHtml()**——
|
||||||
|
* 不是另写一份渲染逻辑,所以预览长什么样,打印就长什么样,不会两边漂移。
|
||||||
|
* 配色 / 字号沿用 printDoc() 里打印用的那套样式。
|
||||||
|
*
|
||||||
|
* 用法:node tools/make-samples.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'samples', 'sheet-data.json'), 'utf8'));
|
||||||
|
|
||||||
|
/* ---- 抽出生产环境的 sheetToHtml ---- */
|
||||||
|
const appSrc = fs.readFileSync(path.join(ROOT, 'assets', 'app.js'), 'utf8');
|
||||||
|
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
|
||||||
|
if (!m) {
|
||||||
|
console.error('未能从 assets/app.js 抽取 sheetToHtml');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const esc = s => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
|
||||||
|
|
||||||
|
/* ---- 沿用 printDoc() 里的打印配色 ---- */
|
||||||
|
const PRINT_CSS = [
|
||||||
|
'body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#222;background:#f7f7f7;margin:0;padding:24px}',
|
||||||
|
'.page{background:#fff;border:1px solid #e3e3e3;border-radius:6px;padding:20px 22px;margin:0 auto 20px;max-width:1100px}',
|
||||||
|
'.page h2{font-size:16px;margin:0 0 4px}',
|
||||||
|
'.page .sub{font-size:12px;color:#777;margin:0 0 14px}',
|
||||||
|
// 与 printDoc() 里的打印样式保持一致(表头渲染成 <td>,所以底色写 thead td)
|
||||||
|
'table.bt{border-collapse:collapse;border:1px solid #999;width:100%;background:#fff;font-size:11px}',
|
||||||
|
'table.bt td,table.bt th{border:1px solid #999;text-align:center;padding:3px 5px}',
|
||||||
|
'table.bt th,table.bt thead td{background:#eaeaea;font-weight:700}',
|
||||||
|
'table.bt thead tr:first-child td{background:#eaeaea}',
|
||||||
|
'table.bt td.absent{color:#888}',
|
||||||
|
'table.bt td.max{color:#d0021b;font-weight:700}',
|
||||||
|
'table.bt td.pass{background:#63c384;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}',
|
||||||
|
'html,body,.pass,.absent,.max{-webkit-print-color-adjust:exact;print-color-adjust:exact}',
|
||||||
|
'.legend{margin:10px 0 0;font-size:12px;color:#555}',
|
||||||
|
'.legend i{display:inline-block;width:11px;height:11px;vertical-align:-1px;margin:0 4px 0 12px}',
|
||||||
|
'.legend .r{color:#d0021b;font-weight:700}',
|
||||||
|
'.legend .g{background:#63c384;font-weight:700}',
|
||||||
|
'.sign{margin-top:16px;font-size:12px}',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
function render(item) {
|
||||||
|
const s = item.sheet;
|
||||||
|
const table = sheetToHtml(s);
|
||||||
|
const meta = item.meta || {};
|
||||||
|
const title = esc(item.title);
|
||||||
|
const sub = meta.exam_name
|
||||||
|
? esc(meta.exam_name) + ' · 合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿'
|
||||||
|
: '合格线 ' + meta.pass_line + ' 分,达 ' + meta.pass_times + ' 次标绿';
|
||||||
|
return [
|
||||||
|
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">',
|
||||||
|
'<title>' + title + '(预览)</title><style>' + PRINT_CSS + '</style></head><body>',
|
||||||
|
'<div class="page"><h2>' + title + '</h2><p class="sub">' + sub + '</p>',
|
||||||
|
table,
|
||||||
|
'<p class="legend">标记说明:<span class="r">最高分</span> 标红、'
|
||||||
|
+ '<i class="r">红字</i>;<span class="g">合格次数</span> 始终显示(含 0 次),'
|
||||||
|
+ '达 ' + meta.pass_times + ' 次标绿、<i class="g">绿底</i>;未达标显示次数、不标绿。',
|
||||||
|
'<p class="sign">教师签字:______________ 教研组长签字:______________ 日期:____________</p>',
|
||||||
|
'</div></body></html>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(ROOT, 'docs', 'samples');
|
||||||
|
fs.writeFileSync(path.join(outDir, 'preview-multi-block.html'), render(data.block));
|
||||||
|
fs.writeFileSync(path.join(outDir, 'preview-single-wide.html'), render(data.wide));
|
||||||
|
|
||||||
|
console.log('已生成:');
|
||||||
|
console.log(' docs/samples/preview-multi-block.html (多考试块表)');
|
||||||
|
console.log(' docs/samples/preview-single-wide.html (单场宽表)');
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 生成「导出 / 打印」新版式的可打开样例:
|
||||||
|
* 1) docs/samples/export-multi-block.xlsx —— 多考试块表(两行表头 + 合格次数 + 红绿标记)
|
||||||
|
* 2) docs/samples/export-single-wide.xlsx —— 单场宽表(每人一行、第几次做列名)
|
||||||
|
* 3) docs/samples/sheet-data.json —— 供 make-samples.js 渲染预览 HTML
|
||||||
|
*
|
||||||
|
* 数据是一次性写死的示例数据(与 tests/test-multi-export.php 的截图场景同一套口径),
|
||||||
|
* 不是真实导出结果,仅用于看版式。
|
||||||
|
*
|
||||||
|
* 运行:php tools/make-samples.php
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
require __DIR__ . '/../lib/multi_export.php';
|
||||||
|
|
||||||
|
/** 构造一名学生的若干次考试记录(成绩按时间升序) */
|
||||||
|
function sample_stu(string $sid, string $name, float $best, array $scoresAsc, array $tempMarks = []): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
$t = 1000000;
|
||||||
|
foreach ($scoresAsc as $i => $sc) {
|
||||||
|
$t += 600;
|
||||||
|
$list[] = [
|
||||||
|
'idx' => $i + 1,
|
||||||
|
'submit_ts' => $t,
|
||||||
|
'score' => $sc,
|
||||||
|
'temp_save' => !empty($tempMarks[$i]),
|
||||||
|
'duration' => 120,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'sid' => $sid,
|
||||||
|
'name' => $name,
|
||||||
|
'cls' => '1班',
|
||||||
|
'grp' => 'A',
|
||||||
|
'score' => end($scoresAsc),
|
||||||
|
'best_score' => $best,
|
||||||
|
'attempts' => count($scoresAsc),
|
||||||
|
'attempts_list' => $list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 示例数据:与需求截图同口径(合格线 110,阈值 1 次) ---- */
|
||||||
|
$exams = [
|
||||||
|
// 「未考」的口径是「整场考试里根本没有这个学号」——不是给个空成绩,
|
||||||
|
// 所以李四整场从数学的 rows 里去掉(写在 rows 里只会渲染成空白单元格)。
|
||||||
|
'm' => ['name' => '第一次月考·数学', 'rows' => [
|
||||||
|
sample_stu('356001', '张三', 136.0, [112.0, 100.0, 136.0, 127.0]),
|
||||||
|
sample_stu('356003', '王五', 95.0, [95.0]), // 未达合格线 → 不标绿
|
||||||
|
]],
|
||||||
|
'e' => ['name' => '第一次月考·英语', 'rows' => [
|
||||||
|
sample_stu('356001', '张三', 112.0, [112.0, 99.0]),
|
||||||
|
sample_stu('356002', '李四', 133.0, [130.0, 129.0, 133.0]),
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$opts = [
|
||||||
|
'pass_line' => 110, // 本卷满分 130 × 合格率 85%
|
||||||
|
'pass_times' => 1, // 达标阈值
|
||||||
|
'mark_best' => true, // 最高分标红
|
||||||
|
'pass_row' => true, // 输出「合格次数」行
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---- 1) 多考试块表 ---- */
|
||||||
|
$block = ny_assemble_block_sheet($exams, $opts);
|
||||||
|
$blockBin = XlsxWriter::renderMulti([[
|
||||||
|
'name' => '每次考试',
|
||||||
|
'rows' => $block['rows'],
|
||||||
|
'widths' => $block['widths'] ?? [],
|
||||||
|
'merges' => $block['merges'],
|
||||||
|
'header_rows' => $block['header_rows'],
|
||||||
|
'cell_styles' => $block['cell_styles'] ?? [],
|
||||||
|
]]);
|
||||||
|
$blockPath = __DIR__ . '/../docs/samples/export-multi-block.xlsx';
|
||||||
|
file_put_contents($blockPath, $blockBin);
|
||||||
|
|
||||||
|
/* ---- 2) 单场宽表(只导出一场考试时的版式) ---- */
|
||||||
|
$wideRes = ny_assemble_attempt_wide_sheet($exams['m'], $opts);
|
||||||
|
$wideBin = XlsxWriter::renderMulti([[
|
||||||
|
'name' => '每次考试',
|
||||||
|
'rows' => $wideRes['rows'],
|
||||||
|
'widths' => $wideRes['widths'] ?? [],
|
||||||
|
'merges' => $wideRes['merges'],
|
||||||
|
'header_rows' => $wideRes['header_rows'],
|
||||||
|
'cell_styles' => $wideRes['cell_styles'] ?? [],
|
||||||
|
]]);
|
||||||
|
$widePath = __DIR__ . '/../docs/samples/export-single-wide.xlsx';
|
||||||
|
file_put_contents($widePath, $wideBin);
|
||||||
|
|
||||||
|
/* ---- 3) 交给 Node 渲染预览 HTML(用 app.js 里的 sheetToHtml,避免两份实现漂移) ---- */
|
||||||
|
$data = [
|
||||||
|
'block' => [
|
||||||
|
'title' => '多考试导出 · 每次考试(两行表头)',
|
||||||
|
'sheet' => [
|
||||||
|
'rows' => $block['rows'],
|
||||||
|
'merges' => $block['merges'],
|
||||||
|
'header_rows' => $block['header_rows'],
|
||||||
|
'cell_styles' => $block['cell_styles'] ?? [],
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'pass_line' => $opts['pass_line'],
|
||||||
|
'pass_times' => $opts['pass_times'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'wide' => [
|
||||||
|
'title' => '单场导出 · 每次考试(每人一行的宽表)',
|
||||||
|
'sheet' => [
|
||||||
|
'rows' => $wideRes['rows'],
|
||||||
|
'merges' => $wideRes['merges'],
|
||||||
|
'header_rows' => $wideRes['header_rows'],
|
||||||
|
'cell_styles' => $wideRes['cell_styles'] ?? [],
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'pass_line' => $opts['pass_line'],
|
||||||
|
'pass_times' => $opts['pass_times'],
|
||||||
|
'exam_name' => $exams['m']['name'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
file_put_contents(__DIR__ . '/../docs/samples/sheet-data.json', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
echo '块表:' . count($block['rows']) . ' 行 / ' . count($block['merges']) . ' 处合并 / '
|
||||||
|
. count($block['cell_styles'] ?? []) . " 处标记 → " . realpath($blockPath) . PHP_EOL;
|
||||||
|
echo '宽表:' . count($wideRes['rows']) . ' 行 / ' . count($wideRes['merges']) . ' 处合并 / '
|
||||||
|
. count($wideRes['cell_styles'] ?? []) . " 处标记 → " . realpath($widePath) . PHP_EOL;
|
||||||
|
echo '预览数据:' . realpath(__DIR__ . '/../docs/samples/sheet-data.json') . PHP_EOL;
|
||||||
@@ -13,7 +13,30 @@ const PUB_B64 = Buffer.from(publicKey).toString('base64');
|
|||||||
const PASSWORD = 'surveyking';
|
const PASSWORD = 'surveyking';
|
||||||
let TOKEN = '';
|
let TOKEN = '';
|
||||||
|
|
||||||
const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students;
|
/* 名单:优先用 data/roster.json(真实名单,答卷也按它生成)。
|
||||||
|
没有该文件时用内置样例名单兜底,免得整个 e2e 因为缺文件连不上——
|
||||||
|
mock 只关心名单的条数与姓名,真实字段由 demoRoster() 补齐。 */
|
||||||
|
function demoRoster() {
|
||||||
|
const out = [];
|
||||||
|
const classes = ['高一(1)班', '高一(2)班'];
|
||||||
|
const groups = ['A组', 'B组'];
|
||||||
|
for (let i = 1; i <= 40; i++) {
|
||||||
|
// 名字必须与下方 PARTNERS 的 userName 一致,否则答卷匹配不到名单
|
||||||
|
const id = String(90000 + i);
|
||||||
|
out.push({ sid: id, name: id, cls: classes[i % 2], grp: groups[i % 2] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function loadRoster() {
|
||||||
|
const p = require('path').join(__dirname, '..', 'data', 'roster.json');
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(fs.readFileSync(p, 'utf8')).students;
|
||||||
|
if (Array.isArray(list) && list.length) return { list, from: 'data/roster.json' };
|
||||||
|
} catch (e) { /* 没有名单文件,走内置兜底 */ }
|
||||||
|
return { list: demoRoster(), from: '内置样例名单(data/roster.json 不存在)' };
|
||||||
|
}
|
||||||
|
const ROSTER = loadRoster();
|
||||||
|
const roster = ROSTER.list;
|
||||||
|
|
||||||
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
|
// 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字
|
||||||
const answers = roster.slice(0, 30).map((s, i) => {
|
const answers = roster.slice(0, 30).map((s, i) => {
|
||||||
@@ -43,6 +66,7 @@ answers.push({
|
|||||||
|
|
||||||
const SURVEY = {
|
const SURVEY = {
|
||||||
id: 'root', title: '高一数学月考', type: 'survey',
|
id: 'root', title: '高一数学月考', type: 'survey',
|
||||||
|
surveySetting: {},
|
||||||
children: [
|
children: [
|
||||||
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
|
||||||
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
{ id: 'q1', type: 'text', title: '<p>姓名</p>' },
|
||||||
@@ -59,6 +83,19 @@ const PROJECTS = [
|
|||||||
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
|
{ id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// p1 的「受访者」参与者 = 应考名单(真实卷王模型:t_project_partner type=RESPONDENT_IMP_USER=3)
|
||||||
|
// status: 0未访问 / 1已访问未答 / 2已答题
|
||||||
|
const PARTNERS = {
|
||||||
|
p1: [
|
||||||
|
{ id: 'pp1', type: 3, userName: '90001', userId: '90001', status: 0 },
|
||||||
|
{ id: 'pp2', type: 3, userName: '90002', userId: '90002', status: 0 },
|
||||||
|
{ id: 'pp3', type: 3, userName: '90003', userId: '90003', status: 0 },
|
||||||
|
{ id: 'pp4', type: 3, userName: '90004', userId: '90004', status: 0 },
|
||||||
|
{ id: 'pp5', type: 3, userName: '90005', userId: '90005', status: 0 },
|
||||||
|
],
|
||||||
|
p2: [],
|
||||||
|
};
|
||||||
|
|
||||||
function send(res, code, obj, headers) {
|
function send(res, code, obj, headers) {
|
||||||
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
|
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
|
||||||
res.end(JSON.stringify(obj));
|
res.end(JSON.stringify(obj));
|
||||||
@@ -100,7 +137,7 @@ const server = http.createServer((req, res) => {
|
|||||||
Buffer.from(pwd, 'base64')
|
Buffer.from(pwd, 'base64')
|
||||||
).toString('utf8');
|
).toString('utf8');
|
||||||
} catch (e) { plain = '<<解密失败>>'; }
|
} catch (e) { plain = '<<解密失败>>'; }
|
||||||
if (j.username !== 'admin' || plain !== PASSWORD) {
|
if (j.username !== 'mock' || plain !== PASSWORD) {
|
||||||
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
|
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
|
||||||
}
|
}
|
||||||
TOKEN = 'mock.jwt.token.' + Date.now();
|
TOKEN = 'mock.jwt.token.' + Date.now();
|
||||||
@@ -128,11 +165,17 @@ const server = http.createServer((req, res) => {
|
|||||||
const slice = all.slice((cur - 1) * size, cur * size);
|
const slice = all.slice((cur - 1) * size, cur * size);
|
||||||
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
|
return send(res, 200, { code: 0, data: { total: all.length, list: slice } });
|
||||||
}
|
}
|
||||||
|
if (p === '/api/project/partner/list') {
|
||||||
|
const pid = u.searchParams.get('projectId');
|
||||||
|
const list = PARTNERS[pid] || [];
|
||||||
|
return send(res, 200, { code: 0, data: { total: list.length, list } });
|
||||||
|
}
|
||||||
|
|
||||||
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
|
return send(res, 200, { code: 404, data: null, msg: 'no route ' + p });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(19911, '127.0.0.1', () => {
|
server.listen(19911, '127.0.0.1', () => {
|
||||||
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD);
|
console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD
|
||||||
|
+ '\n名单来源:' + ROSTER.from + '(' + roster.length + ' 人)');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* 多考试打印渲染回归测试
|
||||||
|
*
|
||||||
|
* 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的
|
||||||
|
* 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底)
|
||||||
|
* 翻成带 colspan / rowspan 的 HTML。
|
||||||
|
* 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移),
|
||||||
|
* 用与 tests/test-multi-export.php 同一套版式校验渲染结果。
|
||||||
|
*
|
||||||
|
* 运行:node tools/test-block-print.js
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, ok, extra) {
|
||||||
|
if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */
|
||||||
|
const appPath = path.join(__dirname, '..', 'assets', 'app.js');
|
||||||
|
const appSrc = fs.readFileSync(appPath, 'utf8');
|
||||||
|
const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/);
|
||||||
|
chk('能从 assets/app.js 抽到 sheetToHtml', !!m);
|
||||||
|
if (!m) {
|
||||||
|
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0];
|
||||||
|
const esc = s => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const sheetToHtml = new Function('esc', 'return ' + m[0])(esc);
|
||||||
|
|
||||||
|
/* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ----------
|
||||||
|
列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语
|
||||||
|
合格线 110,阈值 1 次。 */
|
||||||
|
const FIXTURE = {
|
||||||
|
header_rows: 2,
|
||||||
|
rows: [
|
||||||
|
['学号', '姓名', '第几次', '数学', '', '英语', ''],
|
||||||
|
['', '', '', '成绩', '', '成绩', ''],
|
||||||
|
// 张三:数学 3 次(112/100/136),英语 1 次(112)
|
||||||
|
['356001', '张三', '1', '112', '', '112', ''],
|
||||||
|
['', '', '2', '100', '', '', ''],
|
||||||
|
['', '', '3', '136', '', '', ''],
|
||||||
|
['', '', '合格次数', '3', '', '1', ''],
|
||||||
|
// 李四:数学整场未考,英语 3 次(130/129/133)
|
||||||
|
['356002', '李四', '1', '未考', '', '130', ''],
|
||||||
|
['', '', '2', '', '', '129', ''],
|
||||||
|
['', '', '3', '', '', '133', ''],
|
||||||
|
['', '', '合格次数', '', '', '3', ''],
|
||||||
|
// 王五:数学 1 次(88,未达合格线),英语整场未考
|
||||||
|
['356003', '王五', '1', '88', '', '未考', ''],
|
||||||
|
['', '', '合格次数', '0', '', '', ''],
|
||||||
|
],
|
||||||
|
merges: [
|
||||||
|
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
|
||||||
|
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试
|
||||||
|
[7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行
|
||||||
|
[11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列
|
||||||
|
],
|
||||||
|
cell_styles: {
|
||||||
|
'5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字
|
||||||
|
'9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字
|
||||||
|
'6,4': 'green', // 张三 数学合格次数 3 → 绿底
|
||||||
|
'6,6': 'green', // 张三 英语合格次数 1 → 绿底
|
||||||
|
'10,6': 'green', // 李四 英语合格次数 3 → 绿底
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = sheetToHtml(FIXTURE);
|
||||||
|
const thead = (html.match(/<thead>([\s\S]*?)<\/thead>/) || [])[1] || '';
|
||||||
|
const tbody = (html.match(/<tbody>([\s\S]*?)<\/tbody>/) || [])[1] || '';
|
||||||
|
const count = (s, needle) => s.split(needle).length - 1;
|
||||||
|
|
||||||
|
/* ---------- 3) 断言 ---------- */
|
||||||
|
chk('表头 2 行进 <thead>(多页打印自动重复)', count(thead, '<tr>') === 2 && count(tbody, '<tr>') === 10,
|
||||||
|
'thead=' + count(thead, '<tr>') + ' tbody=' + count(tbody, '<tr>'));
|
||||||
|
chk('学号表头纵向合并 2 行', thead.indexOf('<td rowspan="2">学号</td>') >= 0);
|
||||||
|
chk('姓名表头纵向合并 2 行', thead.indexOf('<td rowspan="2">姓名</td>') >= 0);
|
||||||
|
chk('第几次表头纵向合并 2 行', thead.indexOf('<td rowspan="2">第几次</td>') >= 0);
|
||||||
|
chk('考试名称横跨 2 列',
|
||||||
|
thead.indexOf('<td colspan="2">数学</td>') >= 0 && thead.indexOf('<td colspan="2">英语</td>') >= 0);
|
||||||
|
chk('第 2 行表头是「成绩」小标题', thead.indexOf('<td>成绩</td>') >= 0);
|
||||||
|
chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('<td rowspan="4">356001</td>') >= 0);
|
||||||
|
chk('李四学号纵向合并 4 行', tbody.indexOf('<td rowspan="4">356002</td>') >= 0);
|
||||||
|
chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('<td>356003</td>') >= 0);
|
||||||
|
chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '<td>合格次数</td>') === 3,
|
||||||
|
'实际 ' + count(tbody, '<td>合格次数</td>'));
|
||||||
|
chk('未考合并 2 列 × 4 行(李四·数学)',
|
||||||
|
tbody.indexOf('<td rowspan="4" colspan="2" class="absent">未考</td>') >= 0);
|
||||||
|
chk('未考合并 2 列 × 1 行(王五·英语)',
|
||||||
|
tbody.indexOf('<td colspan="2" class="absent">未考</td>') >= 0);
|
||||||
|
chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2,
|
||||||
|
'实际 ' + count(tbody, 'class="max">'));
|
||||||
|
chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3,
|
||||||
|
'实际 ' + count(tbody, 'class="pass">'));
|
||||||
|
chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0</td>') >= 0 && tbody.indexOf('class="pass">0') < 0);
|
||||||
|
|
||||||
|
/* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */
|
||||||
|
const covered = new Set();
|
||||||
|
FIXTURE.merges.forEach(mg => {
|
||||||
|
for (let r = mg[0] - 1; r <= mg[2] - 1; r++) {
|
||||||
|
for (let c = mg[1] - 1; c <= mg[3] - 1; c++) {
|
||||||
|
if (r === mg[0] - 1 && c === mg[1] - 1) continue;
|
||||||
|
covered.add(r + '|' + c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0);
|
||||||
|
const eaten = FIXTURE.rows.reduce((s, row, r) =>
|
||||||
|
s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0);
|
||||||
|
chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')',
|
||||||
|
count(html, '<td') === capacity - eaten, '实际 ' + count(html, '<td'));
|
||||||
|
|
||||||
|
/* 标记只落在 cell_styles 指定的格子上:合并首格带上 class 且 rowspan/colspan 完整,
|
||||||
|
同行未参与合并的格子不受牵连,越界键被忽略 */
|
||||||
|
const markHtml = sheetToHtml({
|
||||||
|
rows: [['a', 'b', 'c'], ['1', '2', '3']],
|
||||||
|
merges: [[1, 1, 2, 2]],
|
||||||
|
cell_styles: { '1,1': 'green', '99,99': 'red' },
|
||||||
|
header_rows: 1,
|
||||||
|
});
|
||||||
|
chk('合并首格带标记:class 正确且 rowspan/colspan 完整',
|
||||||
|
markHtml.indexOf('<td rowspan="2" colspan="2" class="pass">a</td>') >= 0, markHtml);
|
||||||
|
chk('同行未合并格不受相邻合并影响', markHtml.indexOf('<td>c</td>') >= 0);
|
||||||
|
chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0);
|
||||||
|
|
||||||
|
chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)',
|
||||||
|
PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0
|
||||||
|
&& PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0);
|
||||||
|
/* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */
|
||||||
|
chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)',
|
||||||
|
/print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0);
|
||||||
|
chk('绿底单元格自己也声明了 print-color-adjust',
|
||||||
|
/td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS));
|
||||||
|
/* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */
|
||||||
|
chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页',
|
||||||
|
/h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS));
|
||||||
|
/* 表头渲染成 <td>,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */
|
||||||
|
chk('表头底色命中实际渲染的 <td>(thead td,不是死规则 th)',
|
||||||
|
/table\.bt thead td\{background:/.test(PRINT_CSS));
|
||||||
|
chk('HTML 已转义',
|
||||||
|
sheetToHtml({ rows: [['<b>x</b>']], merges: [], header_rows: 1 }).indexOf('<b>') >= 0);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS };
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
|
||||||
|
// 运行:node tools/test-config-save.js
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PORT = 18081;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
|
||||||
|
// 1) 备份 / 准备文件存储模式
|
||||||
|
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
|
||||||
|
// 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。
|
||||||
|
// Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...;
|
||||||
|
// 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
const cfgBak = cfgFile + '.bak';
|
||||||
|
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||||
|
const tokenFile = path.join(DATA, 'cache', 'token.json');
|
||||||
|
const tokenBak = tokenFile + '.bak';
|
||||||
|
const hasToken = fs.existsSync(tokenFile);
|
||||||
|
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||||
|
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
||||||
|
|
||||||
|
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
|
||||||
|
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
|
||||||
|
const dbFile = path.join(DATA, 'ny.db');
|
||||||
|
const dbBak = dbFile + '.bak';
|
||||||
|
const hasDb = fs.existsSync(dbFile);
|
||||||
|
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
|
||||||
|
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:19911',
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: 'mock',
|
||||||
|
password: 'surveyking',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
include_temp_save: false,
|
||||||
|
dedupe: true,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
full_scores: {},
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
// 统一收尾钩子:恢复原 config + token + kill 子进程。任何退出路径都会触发
|
||||||
|
let cleanedUp = false;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
try { srv && srv.kill('SIGKILL'); } catch(e){}
|
||||||
|
try { mock && mock.kill('SIGKILL'); } catch(e){}
|
||||||
|
try {
|
||||||
|
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
|
||||||
|
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||||
|
} catch(e) { console.error('cleanup cfg failed:', e.message); }
|
||||||
|
try {
|
||||||
|
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||||
|
} catch(e) { console.error('cleanup token failed:', e.message); }
|
||||||
|
// 恢复 SQLite(本轮测试新建的 ny.db 丢弃,换回原有的)
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
|
||||||
|
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
|
||||||
|
} catch(e) { console.error('cleanup db failed:', e.message); }
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
// 2) 启动 mock 卷王
|
||||||
|
let mock = null;
|
||||||
|
let srv = null;
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
|
||||||
|
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
|
||||||
|
|
||||||
|
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
|
||||||
|
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
|
||||||
|
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
|
||||||
|
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function req(method, path, body, headers) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 写操作自动附带 CSRF token(后端对所有写 action 强制校验)
|
||||||
|
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
|
||||||
|
const data = body ? JSON.stringify(body) : '';
|
||||||
|
const h = Object.assign({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(data),
|
||||||
|
}, headers || {});
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
|
||||||
|
let chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
let j = null;
|
||||||
|
try { j = JSON.parse(text); } catch (e) {}
|
||||||
|
// 收集 Set-Cookie
|
||||||
|
if (res.headers['set-cookie']) {
|
||||||
|
for (const sc of res.headers['set-cookie']) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
|
||||||
|
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
|
||||||
|
h['__followed'] = 1;
|
||||||
|
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
|
||||||
|
return resolve(req('GET', res.headers.location, null, h));
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (data) r.write(data);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = ''; // 从 config_get 取到的会话 CSRF token,POST 写请求自动附带
|
||||||
|
|
||||||
|
function formPost(path, fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = new URLSearchParams(fields).toString();
|
||||||
|
const h = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
};
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
|
||||||
|
let chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (res.headers['set-cookie']) {
|
||||||
|
for (const sc of res.headers['set-cookie']) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(body); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
// 走 login.php 的表单登录拿会话
|
||||||
|
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// 等服务起来
|
||||||
|
await delay(2000);
|
||||||
|
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
const http = require('http');
|
||||||
|
const testCookie = await new Promise((resolve) => {
|
||||||
|
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
|
||||||
|
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
|
||||||
|
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
|
||||||
|
const text = Buffer.concat(c).toString();
|
||||||
|
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
|
||||||
|
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
|
||||||
|
if (text.indexOf('登录失败') >= 0) {
|
||||||
|
const m = text.match(/登录失败[^<]+/);
|
||||||
|
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
|
||||||
|
} else {
|
||||||
|
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
|
||||||
|
}
|
||||||
|
// 拿同一 cookie 调 auth_check
|
||||||
|
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
|
||||||
|
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
|
||||||
|
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
|
||||||
|
resolve(cookie);
|
||||||
|
});
|
||||||
|
}).end();
|
||||||
|
});
|
||||||
|
}).end(body);
|
||||||
|
});
|
||||||
|
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
|
||||||
|
// 同步 jar
|
||||||
|
cookieJar.length = 0;
|
||||||
|
testCookie.split('; ').forEach(c => cookieJar.push(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 1) 首次登录应成功 ---');
|
||||||
|
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
|
||||||
|
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
|
||||||
|
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
|
||||||
|
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
|
||||||
|
// 复查 auth(直接走 Session ID 路径)
|
||||||
|
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
|
||||||
|
console.log(' [debug] auth_check:', ac.status, ac.raw);
|
||||||
|
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
|
||||||
|
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
|
||||||
|
console.log(' [fatal] 未登录,停止测试');
|
||||||
|
mock.kill('SIGTERM'); srv.kill('SIGTERM');
|
||||||
|
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||||
|
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// 之后用 ping 触发 SurveyKing 登录
|
||||||
|
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now()); console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
|
||||||
|
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
|
||||||
|
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
|
||||||
|
await delay(100);
|
||||||
|
chk('token 已缓存', fs.existsSync(tokenFile));
|
||||||
|
|
||||||
|
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
|
||||||
|
const cg1 = await req('GET', '/api.php?action=config_get');
|
||||||
|
chk('config_get 200', cg1.status === 200);
|
||||||
|
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
|
||||||
|
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
|
||||||
|
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
|
||||||
|
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
|
||||||
|
// 取 CSRF token,后续写请求自动附带
|
||||||
|
CSRF = (cg1.body && cg1.body.data && cg1.body.data.csrf_token) || '';
|
||||||
|
chk('config_get 返回 csrf_token', CSRF !== '', 'csrf=' + CSRF);
|
||||||
|
|
||||||
|
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
|
||||||
|
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
const cs1 = await req('POST', '/api.php?action=config_save', {
|
||||||
|
full_score: 95,
|
||||||
|
});
|
||||||
|
chk('config_save 200', cs1.status === 200);
|
||||||
|
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
|
||||||
|
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
|
||||||
|
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 试卷独立配置(满分/合格率/免考)已从设置移出,改走 project_config ---');
|
||||||
|
// 每卷满分已不在 config_save 里;改为 project_config_save(试卷间隔离)
|
||||||
|
const pc1 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||||
|
full_score: 150, pass_rate: 70, exempt: 'S1,S2',
|
||||||
|
});
|
||||||
|
chk('project_config_save 200', pc1.status === 200, 'status=' + pc1.status + ' ' + JSON.stringify(pc1.body));
|
||||||
|
chk('生效满分=150', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||||
|
&& pc1.body.data.effective.full_score === 150,
|
||||||
|
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||||
|
chk('生效合格率=70', pc1.body && pc1.body.data && pc1.body.data.effective
|
||||||
|
&& Number(pc1.body.data.effective.pass_rate) === 70,
|
||||||
|
JSON.stringify(pc1.body && pc1.body.data && pc1.body.data.effective));
|
||||||
|
|
||||||
|
// 隔离性:p2 未配置 → 跟随全局(注:上面的 config_save 已把全局满分改成 95,
|
||||||
|
// 所以这里不能写死 100,必须与响应里的 global 比对)
|
||||||
|
const pc2 = await req('GET', '/api.php?action=project_config&projectId=p2');
|
||||||
|
const gFs = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.full_score : null;
|
||||||
|
const gPr = pc2.body && pc2.body.data && pc2.body.data.global ? pc2.body.data.global.pass_rate : null;
|
||||||
|
chk('p2 隔离:满分跟随全局(=' + gFs + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||||
|
&& pc2.body.data.effective.full_score === gFs,
|
||||||
|
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gFs);
|
||||||
|
chk('p2 隔离:合格率跟随全局(=' + gPr + ')', pc2.body && pc2.body.data && pc2.body.data.effective
|
||||||
|
&& Number(pc2.body.data.effective.pass_rate) === Number(gPr),
|
||||||
|
JSON.stringify(pc2.body && pc2.body.data && pc2.body.data.effective) + ' global=' + gPr);
|
||||||
|
|
||||||
|
// 免考名单往返
|
||||||
|
const pc3 = await req('GET', '/api.php?action=project_config&projectId=p1');
|
||||||
|
const ex = pc3.body && pc3.body.data && pc3.body.data.cfg && pc3.body.data.cfg.exempt;
|
||||||
|
chk('免考名单往返 = [S1,S2]', Array.isArray(ex) && ex.length === 2 && ex[0] === 'S1' && ex[1] === 'S2',
|
||||||
|
JSON.stringify(ex));
|
||||||
|
|
||||||
|
// 清除本卷设置 → 回到全局
|
||||||
|
const pc4 = await req('POST', '/api.php?action=project_config_save&projectId=p1', {
|
||||||
|
full_score: '', pass_rate: '', exempt: '',
|
||||||
|
});
|
||||||
|
chk('清除后 p1 回到全局满分(=' + gFs + ')', pc4.body && pc4.body.data && pc4.body.data.effective
|
||||||
|
&& pc4.body.data.effective.full_score === gFs,
|
||||||
|
JSON.stringify(pc4.body && pc4.body.data && pc4.body.data.effective) + ' global=' + gFs);
|
||||||
|
|
||||||
|
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
||||||
|
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
|
||||||
|
const cs5 = await req('POST', '/api.php?action=config_save', {
|
||||||
|
password: 'temp-wrong-password',
|
||||||
|
});
|
||||||
|
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
|
||||||
|
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
|
||||||
|
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
|
||||||
|
await delay(200);
|
||||||
|
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||||
|
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
|
||||||
|
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
|
||||||
|
const cs5b = await req('POST', '/api.php?action=config_save', {
|
||||||
|
password: 'surveyking',
|
||||||
|
});
|
||||||
|
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
|
||||||
|
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
|
||||||
|
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
|
||||||
|
await delay(200);
|
||||||
|
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
|
||||||
|
|
||||||
|
console.log('\n--- 6) config_get 永远不带明文密码 ---');
|
||||||
|
const cg2 = await req('GET', '/api.php?action=config_get');
|
||||||
|
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
|
||||||
|
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
|
||||||
|
|
||||||
|
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
|
||||||
|
const cs6 = await req('POST', '/api.php?action=config_save', {
|
||||||
|
username: 'mock',
|
||||||
|
password: 'wrong-password',
|
||||||
|
});
|
||||||
|
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
|
||||||
|
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
|
||||||
|
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
|
||||||
|
|
||||||
|
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
|
||||||
|
// 清理 + 退出(统一走 cleanup 钩子,保证 config/token 一定被还原)
|
||||||
|
cleanup();
|
||||||
|
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
|
||||||
|
})().catch(e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
|
||||||
|
//
|
||||||
|
// 覆盖点:
|
||||||
|
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
|
||||||
|
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
|
||||||
|
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
|
||||||
|
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
|
||||||
|
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
|
||||||
|
//
|
||||||
|
// 运行:node tools/test-dashboard-nav.js
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PORT = 18091;
|
||||||
|
const INI = process.env.PHP_INI || '';
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const MOCK = { user: 'mock', pass: 'surveyking' };
|
||||||
|
|
||||||
|
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
|
||||||
|
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
const DATA = path.resolve(p);
|
||||||
|
process.env.NY_DATA = DATA;
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
|
||||||
|
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
|
||||||
|
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
|
||||||
|
const back = [
|
||||||
|
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
|
||||||
|
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
|
||||||
|
];
|
||||||
|
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
|
||||||
|
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
const sid = String(9100 + ci * 100 + i);
|
||||||
|
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try { fs.unlinkSync(cfgFile); } catch (e) {}
|
||||||
|
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:19911',
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
dedupe: true,
|
||||||
|
include_temp_save: false,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
|
||||||
|
|
||||||
|
let cleanedUp = false, mock = null, srv = null;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
try { srv && srv.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { mock && mock.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'ignore' });
|
||||||
|
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
const cookieJar = [];
|
||||||
|
function collect(res) {
|
||||||
|
for (const sc of (res.headers['set-cookie'] || [])) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
|
||||||
|
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
|
||||||
|
function get(p, jar) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = { 'Accept': 'text/html,application/json' };
|
||||||
|
const ck = jar === undefined ? jarHeader()
|
||||||
|
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
if (ck) h['Cookie'] = ck;
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
|
||||||
|
if (jar === undefined) collect(res);
|
||||||
|
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function post(p, fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = new URLSearchParams(fields).toString();
|
||||||
|
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(data); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
|
||||||
|
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await delay(2200);
|
||||||
|
|
||||||
|
console.log('\n--- 0) 登录 ---');
|
||||||
|
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
|
||||||
|
const ac = await get('/api.php?action=auth_check');
|
||||||
|
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
|
||||||
|
|
||||||
|
console.log('\n--- 1) 首页形态(无 ?class=)---');
|
||||||
|
const home = await get('/dashboard.php');
|
||||||
|
chk('首页返回 200', home.status === 200, 'status=' + home.status);
|
||||||
|
chk('body 带 dash-home、不带 dash-class',
|
||||||
|
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
|
||||||
|
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
|
||||||
|
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
|
||||||
|
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
|
||||||
|
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
|
||||||
|
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
|
||||||
|
chk('首页把 renderBoard 分流到 renderClassCards',
|
||||||
|
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
|
||||||
|
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
|
||||||
|
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
|
||||||
|
|
||||||
|
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
|
||||||
|
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
|
||||||
|
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
|
||||||
|
chk('body 带 dash-class、不带 dash-home',
|
||||||
|
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
|
||||||
|
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
|
||||||
|
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
|
||||||
|
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
|
||||||
|
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
|
||||||
|
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
|
||||||
|
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
|
||||||
|
chk('lockHistory 用 pushState 把本页重新推回栈顶',
|
||||||
|
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
|
||||||
|
/popstate/.test(dashSrc));
|
||||||
|
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
|
||||||
|
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
|
||||||
|
|
||||||
|
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
|
||||||
|
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
|
||||||
|
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
|
||||||
|
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
|
||||||
|
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
|
||||||
|
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
|
||||||
|
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
|
||||||
|
chk('单班页的「← 主系统」靠 CSS 隐藏',
|
||||||
|
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
|
||||||
|
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
|
||||||
|
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
|
||||||
|
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
|
||||||
|
chk('能抽出 clsCardHtml 函数体', !!m);
|
||||||
|
if (m) {
|
||||||
|
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
|
||||||
|
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
|
||||||
|
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
|
||||||
|
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
|
||||||
|
const html = fn({
|
||||||
|
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
|
||||||
|
attempts: 5, avg: 78.5, pass_rate: 66.7,
|
||||||
|
});
|
||||||
|
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
|
||||||
|
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
|
||||||
|
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
|
||||||
|
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
|
||||||
|
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
|
||||||
|
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 5) 进入班级的跳转 ---');
|
||||||
|
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
|
||||||
|
chk('能抽出 enterClass 函数体', !!e);
|
||||||
|
if (e) {
|
||||||
|
chk('跳转时带上 pid + class + auto',
|
||||||
|
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
|
||||||
|
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
|
||||||
|
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
|
||||||
|
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
|
||||||
|
/closest\('\.cls-card'\)/.test(dashSrc));
|
||||||
|
}
|
||||||
|
chk('首页卡片容器类名与样式一致(cls-grid)',
|
||||||
|
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
|
||||||
|
|
||||||
|
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
|
||||||
|
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
|
||||||
|
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
|
||||||
|
if (sgSrc) {
|
||||||
|
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
|
||||||
|
// 这里把 DASH 作为形参传进去,还原同一条作用域链
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
|
||||||
|
const g100 = mk({ full_score: 100, pass_rate: 60 });
|
||||||
|
const g150 = mk({ full_score: 150, pass_rate: 80 });
|
||||||
|
const gCur = mk({ full_score: 100, pass_rate: 95 });
|
||||||
|
|
||||||
|
chk('默认口径:95 绿 / 70 黄 / 50 红',
|
||||||
|
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
|
||||||
|
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
|
||||||
|
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
|
||||||
|
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
|
||||||
|
g150(100).cls === 'score-low', '100→' + g150(100).cls);
|
||||||
|
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
|
||||||
|
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
|
||||||
|
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
|
||||||
|
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
|
||||||
|
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
|
||||||
|
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
|
||||||
|
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
|
||||||
|
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
|
||||||
|
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
|
||||||
|
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
|
||||||
|
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
|
||||||
|
chk('缺成绩/越界分数不套样式',
|
||||||
|
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
|
||||||
|
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
|
||||||
|
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
|
||||||
|
!/window\.DASH/.test(sgSrc[0]));
|
||||||
|
chk('membersTable 也不再走 window.DASH',
|
||||||
|
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
|
||||||
|
const CLS2 = encodeURIComponent('高一(2)班');
|
||||||
|
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
|
||||||
|
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
|
||||||
|
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
|
||||||
|
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
|
||||||
|
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
|
||||||
|
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
|
||||||
|
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
|
||||||
|
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
|
||||||
|
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
|
||||||
|
chk('dashParams 在单班形态下补充 class 参数',
|
||||||
|
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
|
||||||
|
|
||||||
|
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
|
||||||
|
const aj = JSON.parse(anonApi.raw);
|
||||||
|
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
|
||||||
|
chk('classes 只回指定班级',
|
||||||
|
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
|
||||||
|
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
|
||||||
|
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
|
||||||
|
chk('overall 已换成该班汇总且键名兼容',
|
||||||
|
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
|
||||||
|
'attempts_total=' + (aj.data.overall || {}).attempts_total);
|
||||||
|
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
|
||||||
|
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
|
||||||
|
chk('响应带 public 标记', aj.data.public === true);
|
||||||
|
|
||||||
|
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
|
||||||
|
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
|
||||||
|
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
|
||||||
|
|
||||||
|
const homeAnon = await get('/dashboard.php', []);
|
||||||
|
chk('未登录访问大屏首页仍然跳转登录页',
|
||||||
|
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
|
||||||
|
|
||||||
|
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -19,79 +19,146 @@ function chk(name, cond, extra) {
|
|||||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */
|
function sum(a) { return a.reduce((x, y) => x + y, 0); }
|
||||||
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关)
|
function eqArr(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
|
||||||
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
|
|
||||||
JSON.stringify(T.allocQuotas([12, 8], 10)));
|
/* ==================== 均分模式(weights = null) ==================== */
|
||||||
|
// A/B = 12/8 抽 10 → 各 5
|
||||||
|
chk('均分 12/8 抽10 → 5/5', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
|
||||||
// 抽 1 人:余数小组优先
|
// 抽 1 人:余数小组优先
|
||||||
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]',
|
chk('均分 抽1人 → 小组优先', eqArr(T.allocQuotas([12, 8], 1, null), [0, 1]),
|
||||||
JSON.stringify(T.allocQuotas([12, 8], 1)));
|
JSON.stringify(T.allocQuotas([12, 8], 1, null)));
|
||||||
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2
|
// 12/8 抽 3 → 1/2
|
||||||
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]',
|
chk('均分 12/8 抽3 → 1/2', eqArr(T.allocQuotas([12, 8], 3, null), [1, 2]),
|
||||||
JSON.stringify(T.allocQuotas([12, 8], 3)));
|
JSON.stringify(T.allocQuotas([12, 8], 3, null)));
|
||||||
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9
|
// 容量不足:A=1, B=19, 抽 10 → 1/9
|
||||||
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]',
|
chk('均分 容量不足差额再分配', eqArr(T.allocQuotas([1, 19], 10, null), [1, 9]),
|
||||||
JSON.stringify(T.allocQuotas([1, 19], 10)));
|
JSON.stringify(T.allocQuotas([1, 19], 10, null)));
|
||||||
// 全抽 → 每组全量
|
// 全抽
|
||||||
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]',
|
chk('均分 全部抽取 = 各组全量', eqArr(T.allocQuotas([12, 8], 20, null), [12, 8]),
|
||||||
JSON.stringify(T.allocQuotas([12, 8], 20)));
|
JSON.stringify(T.allocQuotas([12, 8], 20, null)));
|
||||||
// n 超过总人数 → 全量
|
// 超额
|
||||||
chk('超额抽取取全量', (() => {
|
chk('均分 超额取全量', eqArr(T.allocQuotas([3, 4], 99, null), [3, 4]),
|
||||||
const q = T.allocQuotas([3, 4], 99);
|
JSON.stringify(T.allocQuotas([3, 4], 99, null)));
|
||||||
return JSON.stringify(q) === '[3,4]';
|
// 20/20 抽 10
|
||||||
})(), JSON.stringify(T.allocQuotas([3, 4], 99)));
|
chk('均分 20/20 抽10 → 5/5', eqArr(T.allocQuotas([20, 20], 10, null), [5, 5]),
|
||||||
// 5:5 经典:A 20 / B 20 抽 10 → 5/5
|
JSON.stringify(T.allocQuotas([20, 20], 10, null)));
|
||||||
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]',
|
// 三组 10/10/10 抽 8
|
||||||
JSON.stringify(T.allocQuotas([20, 20], 10)));
|
chk('均分 三组 10/10/10 抽8', (() => {
|
||||||
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配)
|
const q = T.allocQuotas([10, 10, 10], 8, null);
|
||||||
chk('三组 10/10/10 抽8', (() => {
|
return sum(q) === 8 && Math.max(...q) - Math.min(...q) <= 1;
|
||||||
const q = T.allocQuotas([10, 10, 10], 8);
|
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8, null)));
|
||||||
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1;
|
// 空数组
|
||||||
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8)));
|
chk('空数组返回空', eqArr(T.allocQuotas([], 5, null), []));
|
||||||
// 空组 / 0
|
|
||||||
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]');
|
/* ==================== 加权模式(按人数权重) ==================== */
|
||||||
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz)
|
// 12/8 抽 10 → 6/4(按人数加权)
|
||||||
|
chk('加权 12/8 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [12, 8]), [6, 4]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, [12, 8])));
|
||||||
|
// 12/8 抽 3 → 2/1(raw=[1.8,1.2], base=[1,1], 余数 .8 → A)
|
||||||
|
chk('加权 12/8 抽3 → 2/1', eqArr(T.allocQuotas([12, 8], 3, [12, 8]), [2, 1]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 3, [12, 8])));
|
||||||
|
// 加权抽 1 → 按权重
|
||||||
|
chk('加权 12/8 抽1 → 1/0', eqArr(T.allocQuotas([12, 8], 1, [12, 8]), [1, 0]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 1, [12, 8])));
|
||||||
|
// 自定义比例 6:4
|
||||||
|
chk('自定义 6:4 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [6, 4]), [6, 4]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, [6, 4])));
|
||||||
|
// 自定义 1:1 等价均分
|
||||||
|
chk('自定义 1:1 = 均分', eqArr(T.allocQuotas([12, 8], 10, [1, 1]), [5, 5]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, [1, 1])));
|
||||||
|
// 极端比例 9:1 抽 5
|
||||||
|
chk('自定义 9:1 抽5', (() => {
|
||||||
|
const q = T.allocQuotas([12, 8], 5, [9, 1]);
|
||||||
|
return sum(q) === 5 && q[0] >= 4 && q[1] >= 0 && q[0] <= 12 && q[1] <= 8;
|
||||||
|
})(), JSON.stringify(T.allocQuotas([12, 8], 5, [9, 1])));
|
||||||
|
// 容量约束 [1,19] 抽 10:按权重 1:19 → raw=[0.5,9.5] → base=[0,9] + frac 相同时
|
||||||
|
// 给剩余容量大的组补余数 → A=0, B=10(A 容量只有 1,本来也只可能 0 或 1)
|
||||||
|
chk('加权 容量约束 [1,19] 抽10', (() => {
|
||||||
|
const q = T.allocQuotas([1, 19], 10, [1, 19]);
|
||||||
|
return sum(q) === 10 && q[0] === 0 && q[1] === 10;
|
||||||
|
})(), JSON.stringify(T.allocQuotas([1, 19], 10, [1, 19])));
|
||||||
|
// 全 0 权重 → 退化为均分
|
||||||
|
chk('全0权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, [0, 0]), [5, 5]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, [0, 0])));
|
||||||
|
// null 权重 → 均分
|
||||||
|
chk('null权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]),
|
||||||
|
JSON.stringify(T.allocQuotas([12, 8], 10, null)));
|
||||||
|
|
||||||
|
/* ==================== 守恒 + 不超容量 + fuzz ==================== */
|
||||||
let fuzzOk = true;
|
let fuzzOk = true;
|
||||||
let fuzzBad = '';
|
let fuzzBad = '';
|
||||||
for (let t = 0; t < 1000; t++) {
|
for (let t = 0; t < 1000; t++) {
|
||||||
const g = 1 + Math.floor(Math.random() * 5);
|
const g = 1 + Math.floor(Math.random() * 5);
|
||||||
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
|
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
|
||||||
const n = Math.floor(Math.random() * 60);
|
const n = Math.floor(Math.random() * 60);
|
||||||
const q = T.allocQuotas(sizes, n);
|
// 随机权重
|
||||||
const sum = q.reduce((a, b) => a + b, 0);
|
const weights = Array.from({ length: g }, () => Math.random() * 10);
|
||||||
|
// 50% 概率用 null 走均分
|
||||||
|
const w = Math.random() < 0.5 ? weights : null;
|
||||||
|
const q = T.allocQuotas(sizes, n, w);
|
||||||
const total = sizes.reduce((a, b) => a + b, 0);
|
const total = sizes.reduce((a, b) => a + b, 0);
|
||||||
const expect = Math.min(n, total);
|
const expect = Math.min(n, total);
|
||||||
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
|
if (sum(q) !== expect) { fuzzOk = false; fuzzBad = `sum=${sum(q)} expect=${expect} sizes=${sizes} n=${n} w=${w}`; break; }
|
||||||
let bad = false;
|
let bad = false;
|
||||||
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
|
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
|
||||||
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
|
// 加权模式下"任意两组差 ≤ 1"不一定成立(按权重 9:1 抽 5 应是 5/0)
|
||||||
|
// 但均分模式下应成立
|
||||||
|
if (w === null) {
|
||||||
const allNotFull = q.every((v, i) => v < sizes[i]);
|
const allNotFull = q.every((v, i) => v < sizes[i]);
|
||||||
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
|
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
|
||||||
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; }
|
}
|
||||||
|
if (bad) { fuzzOk = false; fuzzBad = `sizes=${sizes} n=${n} w=${w} q=${q}`; break; }
|
||||||
}
|
}
|
||||||
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
|
chk('1000 组随机 fuzz:守恒+不超容量+(均分时)均衡', fuzzOk, fuzzBad);
|
||||||
|
|
||||||
/* ---------- balanceKeyOf ---------- */
|
/* ==================== balanceKeyOf ==================== */
|
||||||
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
|
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
|
||||||
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
|
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
|
||||||
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
|
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
|
||||||
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
|
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
|
||||||
|
|
||||||
/* ---------- pickBalanced ---------- */
|
/* ==================== pickBalanced ==================== */
|
||||||
{
|
{
|
||||||
|
// 12A + 8B,加权(weights=sizes)抽 10 → 应 6A/4B
|
||||||
const pool = [];
|
const pool = [];
|
||||||
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
|
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
|
||||||
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
|
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
|
||||||
const r = T.pickBalanced(pool, 10, 'grp');
|
const r = T.pickBalanced(pool, 10, 'grp', [12, 8]);
|
||||||
const byGrp = { A: 0, B: 0 };
|
const byGrp = { A: 0, B: 0 };
|
||||||
r.picked.forEach(m => byGrp[m.grp]++);
|
r.picked.forEach(m => byGrp[m.grp]++);
|
||||||
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
chk('加权 pickBalanced 12A/8B 抽10 → 6A/4B', byGrp.A === 6 && byGrp.B === 4,
|
||||||
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/'));
|
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||||
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
|
chk('pickBalanced 详情文案', r.detail.join('/') === 'A 6人/B 4人', r.detail.join('/'));
|
||||||
|
chk('pickBalanced 结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
// 均分(weights=null)抽 10 → 5A/5B
|
||||||
|
const pool = [];
|
||||||
|
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A', cls: 'C' });
|
||||||
|
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B', cls: 'C' });
|
||||||
|
const r = T.pickBalanced(pool, 10, 'grp', null);
|
||||||
|
const byGrp = { A: 0, B: 0 };
|
||||||
|
r.picked.forEach(m => byGrp[m.grp]++);
|
||||||
|
chk('均分 pickBalanced 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5,
|
||||||
|
JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// 自定义比例 9:1 抽 5
|
||||||
|
const pool = [];
|
||||||
|
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A' });
|
||||||
|
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B' });
|
||||||
|
const r = T.pickBalanced(pool, 5, 'grp', [9, 1]);
|
||||||
|
const byGrp = { A: 0, B: 0 };
|
||||||
|
r.picked.forEach(m => byGrp[m.grp]++);
|
||||||
|
chk('自定义 9:1 抽5 → 全部 A', byGrp.A === 5 && byGrp.B === 0,
|
||||||
|
JSON.stringify(byGrp));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// 无维度(key='')走不到
|
||||||
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
|
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
|
||||||
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
|
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null);
|
||||||
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
|
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// e2e: 登录后立刻又报"未配置账号"、反复跳登录页的死循环
|
||||||
|
//
|
||||||
|
// 复现前提:本机配置里账号为空(或没存住)。旧代码 login.php 不看 ny_save_config 结果,
|
||||||
|
// 登录成功页照常显示"登录成功",但下一个请求 ny_load_config() 读到的账号仍是空的
|
||||||
|
// → 抛"未配置卷王登录账号" → 前端 auth 打标 → 跳登录页 → 死循环。
|
||||||
|
//
|
||||||
|
// 本测试锁死三件事:
|
||||||
|
// 1) 登录后凭据必须真的落盘(config_get 能读回账号、has_password=true)
|
||||||
|
// 2) 登录后立刻拉数据必须成功(不再出现"未配置账号")
|
||||||
|
// 3) 真出现鉴权失败时,响应必须带 extra.auth + extra.diag(前端据此停止自动跳转并给出可自查信息)
|
||||||
|
//
|
||||||
|
// 运行:node tools/test-login-loop.js
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PORT = 18087;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
const MOCK = { user: 'mock', pass: 'surveyking' };
|
||||||
|
|
||||||
|
// NY_DATA 指向临时目录:Git Bash 风格 /c/... 需还原成 C:\...,并写回 env 给 PHP 子进程。
|
||||||
|
// 注意:绝不能回退到项目下的 data/ —— 本测试会清空账密并重建库,落到真实目录等于破坏本地配置
|
||||||
|
// (曾因默认回退到 data/ 把 ny.db 删掉)。未显式指定时一律新建临时目录。
|
||||||
|
const os = require('os');
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-login-loop-' + process.pid);
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
const tokenFile = path.join(DATA, 'cache', 'token.json');
|
||||||
|
const dbFile = path.join(DATA, 'ny.db');
|
||||||
|
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||||
|
const hasDb = fs.existsSync(dbFile);
|
||||||
|
if (hasDb) { try { fs.unlinkSync(dbFile); } catch (e) {} }
|
||||||
|
try { fs.unlinkSync(tokenFile); } catch (e) {}
|
||||||
|
|
||||||
|
// 关键:模拟"账号没配/没存住"的现场——连接信息是对的,账号密码是空的
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:19911',
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
dedupe: true,
|
||||||
|
include_temp_save: false,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let cleanedUp = false, mock = null, srv = null;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
try { srv && srv.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try { mock && mock.kill('SIGKILL'); } catch (e) {}
|
||||||
|
try {
|
||||||
|
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg);
|
||||||
|
else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||||
|
} catch (e) {}
|
||||||
|
try { if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile); } catch (e) {}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', e => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'pipe' });
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
|
||||||
|
function collect(res) {
|
||||||
|
for (const sc of (res.headers['set-cookie'] || [])) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function req(method, p, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (body && CSRF && !body._csrf) body = Object.assign({}, body, { _csrf: CSRF });
|
||||||
|
const data = body ? JSON.stringify(body) : '';
|
||||||
|
const h = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method, path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
collect(res);
|
||||||
|
const raw = Buffer.concat(bufs).toString('utf8');
|
||||||
|
let j = null; try { j = JSON.parse(raw); } catch (e) {}
|
||||||
|
resolve({ status: res.statusCode, body: j, raw, loc: res.headers.location });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (data) r.write(data);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formPost(p, fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = new URLSearchParams(fields).toString();
|
||||||
|
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
|
||||||
|
if (jarHeader()) h['Cookie'] = jarHeader();
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
|
||||||
|
const bufs = [];
|
||||||
|
res.on('data', c => bufs.push(c));
|
||||||
|
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
r.write(data); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await delay(2200);
|
||||||
|
|
||||||
|
console.log('\n--- 1) 登录:凭据必须真的落盘(登录前本机账号为空)---');
|
||||||
|
const lg = await formPost('/login.php', { username: MOCK.user, password: MOCK.pass });
|
||||||
|
chk('login.php 返回 200/302', lg.status === 200 || lg.status === 302, 'status=' + lg.status);
|
||||||
|
chk('登录页未出现"没能保存到本机配置"', lg.raw.indexOf('没能保存到本机配置') < 0, (lg.raw.match(/没能保存到本机配置[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('登录页未出现"登录失败"', lg.raw.indexOf('登录失败') < 0, (lg.raw.match(/登录失败[^<]*/) || [''])[0].slice(0, 160));
|
||||||
|
chk('拿到会话 cookie', cookieJar.some(c => c.indexOf('NYSESS=') === 0), JSON.stringify(cookieJar));
|
||||||
|
chk('成功页写入 ny_login_at(前端防重复跳转依据)', lg.raw.indexOf('ny_login_at') >= 0);
|
||||||
|
|
||||||
|
const ac = await req('GET', '/api.php?action=auth_check');
|
||||||
|
chk('auth_check 已登录', ac.body && ac.body.data && ac.body.data.auth === true, ac.raw);
|
||||||
|
if (!(ac.body && ac.body.data && ac.body.data.auth)) { cleanup(); process.exit(1); }
|
||||||
|
CSRF = (ac.body && ac.body.data && ac.body.data.csrf_token) || CSRF;
|
||||||
|
|
||||||
|
const g1 = await req('GET', '/api.php?action=config_get');
|
||||||
|
CSRF = (g1.body && g1.body.data && g1.body.data.csrf_token) || CSRF;
|
||||||
|
chk('账号已回读到配置', g1.body && g1.body.data && g1.body.data.username === MOCK.user, JSON.stringify(g1.body && g1.body.data && g1.body.data.username));
|
||||||
|
chk('has_password=true(密码已存)', g1.body && g1.body.data && g1.body.data.has_password === true);
|
||||||
|
chk('响应不含明文密码', !('password' in ((g1.body && g1.body.data) || {})));
|
||||||
|
|
||||||
|
console.log('\n--- 2) 登录后立刻拉数据:不应再出现"未配置账号" ---');
|
||||||
|
const d1 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 成功', d1.body && d1.body.ok === true, (d1.raw || '').slice(0, 200));
|
||||||
|
chk('data 未报未配置账号', (d1.raw || '').indexOf('未配置卷王登录账号') < 0, (d1.raw || '').slice(0, 160));
|
||||||
|
chk('data 未被标记为鉴权失败', !(d1.body && d1.body.extra && d1.body.extra.auth), JSON.stringify(d1.body && d1.body.extra));
|
||||||
|
|
||||||
|
console.log('\n--- 3) 鉴权真失败时:必须带 auth 标记 + 可自查的诊断 ---');
|
||||||
|
const sv = await req('POST', '/api.php?action=config_save', { password: 'definitely-wrong' });
|
||||||
|
chk('改错密码已保存', sv.body && sv.body.ok === true, (sv.raw || '').slice(0, 160));
|
||||||
|
const d2 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('此时 data 失败', d2.body && d2.body.ok === false, (d2.raw || '').slice(0, 160));
|
||||||
|
chk('extra.auth=true', !!(d2.body && d2.body.extra && d2.body.extra.auth), JSON.stringify(d2.body && d2.body.extra));
|
||||||
|
const diag = d2.body && d2.body.extra && d2.body.extra.diag;
|
||||||
|
chk('extra.diag 存在', !!diag, JSON.stringify(diag));
|
||||||
|
if (diag) {
|
||||||
|
chk('diag.has_username=true(账号其实存住了)', diag.has_username === true);
|
||||||
|
chk('diag 含目录/文件/数据库可写标记', 'dir_writable' in diag && 'cfg_writable' in diag && 'db_writable' in diag);
|
||||||
|
chk('diag 含配置来源', typeof diag.storage === 'string' && diag.storage !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 4) 改回正确密码后应恢复 ---');
|
||||||
|
const sv2 = await req('POST', '/api.php?action=config_save', { password: MOCK.pass });
|
||||||
|
chk('恢复密码已保存', sv2.body && sv2.body.ok === true);
|
||||||
|
const d3 = await req('GET', '/api.php?action=data&projectId=p1&force=1');
|
||||||
|
chk('data 恢复正常', d3.body && d3.body.ok === true, (d3.raw || '').slice(0, 160));
|
||||||
|
|
||||||
|
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// e2e: 上传文件防护(扩展名白名单 + 随机文件名 + 解析后即删)
|
||||||
|
// 运行:node tools/test-upload-guard.js (NY_DEBUG=1 可打印调试信息)
|
||||||
|
//
|
||||||
|
// 覆盖的漏洞:早期版本按 roster_YYYYMMDD_HHMMSS.<原始扩展名> 存在 data/uploads/,
|
||||||
|
// 既不做扩展名校验(可上传 .php 造成 RCE),文件名又可枚举;且 nginx 下 .htaccess 无效。
|
||||||
|
const http = require('http');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PHP_PORT = 18081;
|
||||||
|
const MOCK_PORT = 19911;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
|
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
|
||||||
|
// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。
|
||||||
|
// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的
|
||||||
|
// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const UPLOADS = path.join(DATA, 'uploads');
|
||||||
|
|
||||||
|
// ---------- 备份 / 还原运行时数据 ----------
|
||||||
|
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
|
||||||
|
// roster.json 只能复制不能移走:mock-server 启动时要读它,移走会让 mock 直接崩。
|
||||||
|
const moved = []; // [路径, 备份路径] —— 移走
|
||||||
|
const copied = []; // [路径, 备份路径] —— 复制
|
||||||
|
for (const f of ['ny.db', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.renameSync(p, bak); moved.push([p, bak]); }
|
||||||
|
catch (e) { console.error(' 备份失败(移走)' + f + ':' + e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.copyFileSync(p, bak); copied.push([p, bak]); }
|
||||||
|
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: 'mock',
|
||||||
|
password: 'surveyking',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
include_temp_save: false,
|
||||||
|
dedupe: true,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
pass_rate: 60,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let mock = null, srv = null, cleanedUp = false;
|
||||||
|
function killAll() {
|
||||||
|
for (const p of [srv, mock]) {
|
||||||
|
try { p && p.kill('SIGKILL'); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
killAll();
|
||||||
|
// 丢弃本次测试产生的运行时数据,换回备份
|
||||||
|
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||||
|
}
|
||||||
|
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
|
||||||
|
for (const [p, bak] of moved.concat(copied)) {
|
||||||
|
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
// ---------- 启动依赖服务 ----------
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
mock.on('exit', (code) => { if (DEBUG) console.log(' [debug] mock 退出,code=' + code); });
|
||||||
|
const phpArgs = INI.split(/\s+/).filter(Boolean)
|
||||||
|
.concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||||||
|
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
|
||||||
|
/** 轮询直到端口可连接(避免使用固定 sleep 导致的偶发失败) */
|
||||||
|
function waitPort(port, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
(function attempt() {
|
||||||
|
const s = net.connect({ host: '127.0.0.1', port });
|
||||||
|
const done = (ok) => { s.destroy(); resolve(ok); };
|
||||||
|
s.setTimeout(800, () => done(false));
|
||||||
|
s.on('connect', () => done(true));
|
||||||
|
s.on('error', () => {
|
||||||
|
if (Date.now() > deadline) return done(false);
|
||||||
|
setTimeout(attempt, 300);
|
||||||
|
});
|
||||||
|
s.on('timeout', () => {
|
||||||
|
if (Date.now() > deadline) return done(false);
|
||||||
|
setTimeout(attempt, 300);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HTTP 辅助 ----------
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
function baseHeaders() {
|
||||||
|
const h = {};
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function absorb(res) {
|
||||||
|
for (const sc of res.headers['set-cookie'] || []) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function request(method, urlPath, headers, bodyBuf) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = Object.assign({}, headers || {});
|
||||||
|
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
absorb(res);
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
let j = null;
|
||||||
|
try { j = JSON.parse(raw); } catch (e) {}
|
||||||
|
resolve({ status: res.statusCode, body: j, raw });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (bodyBuf) r.write(bodyBuf);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const get = (p) => request('GET', p, baseHeaders());
|
||||||
|
function formPost(p, fields) {
|
||||||
|
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||||||
|
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||||||
|
}
|
||||||
|
function upload(urlPath, fields, file) {
|
||||||
|
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||||||
|
const parts = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
}
|
||||||
|
parts.push(Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\n` +
|
||||||
|
'Content-Type: application/octet-stream\r\n\r\n'));
|
||||||
|
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||||||
|
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||||
|
const body = Buffer.concat(parts);
|
||||||
|
return request('POST', urlPath,
|
||||||
|
Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), body);
|
||||||
|
}
|
||||||
|
function listUploads() {
|
||||||
|
if (!fs.existsSync(UPLOADS)) return [];
|
||||||
|
return fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 断言 ----------
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||||||
|
const phpUp = await waitPort(PHP_PORT, 15000);
|
||||||
|
chk('mock 卷王服务已就绪', mockUp);
|
||||||
|
chk('PHP 内置服务器已就绪', phpUp);
|
||||||
|
if (!mockUp || !phpUp) {
|
||||||
|
console.log(' 服务未就绪,终止测试');
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录(最多重试 3 次)
|
||||||
|
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||||
|
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
const ac = await get('/api.php?action=auth_check');
|
||||||
|
if (DEBUG) {
|
||||||
|
const m = (lg.raw || '').match(/登录失败[\s\S]{0,160}?<\/div>|尝试次数[^<]*/);
|
||||||
|
console.log(' [debug] login ' + lg.status + ' auth=' + JSON.stringify(ac.body && ac.body.data) +
|
||||||
|
' err=' + (m ? m[0].replace(/\s+/g, ' ').slice(0, 160) : '(none)'));
|
||||||
|
}
|
||||||
|
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||||||
|
const cg = await get('/api.php?action=config_get');
|
||||||
|
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chk('已登录并取到 CSRF token', CSRF !== '');
|
||||||
|
if (CSRF === '') {
|
||||||
|
console.log(' 登录失败,终止测试');
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 1) 危险扩展名必须被拒绝 ---');
|
||||||
|
const evil = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'shell.php', content: '<?php system($_GET["c"]); ?>',
|
||||||
|
});
|
||||||
|
// 失败响应字段为 msg(ny_fail 输出 {"ok":false,"msg":...})
|
||||||
|
const evilMsg = (evil.body && (evil.body.error || evil.body.msg)) || '';
|
||||||
|
chk('上传 .php 被拒绝', !!(evil.body && evil.body.ok === false), evilMsg || evil.raw.slice(0, 120));
|
||||||
|
chk('拒绝原因是扩展名白名单', /仅支持/.test(evilMsg), evilMsg);
|
||||||
|
|
||||||
|
const phtml = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'a.phtml', content: 'x',
|
||||||
|
});
|
||||||
|
chk('上传 .phtml 被拒绝', !!(phtml.body && phtml.body.ok === false));
|
||||||
|
|
||||||
|
console.log('\n--- 2) 被拒绝后不留残留文件 ---');
|
||||||
|
chk('data/uploads 无残留', listUploads().length === 0, JSON.stringify(listUploads()));
|
||||||
|
|
||||||
|
console.log('\n--- 3) 合法 csv 正常导入且文件即删 ---');
|
||||||
|
const csv = '学号,姓名,班级\n90001,测试甲,1班\n90002,测试乙,1班\n';
|
||||||
|
const good = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||||||
|
});
|
||||||
|
chk('合法 .csv 导入成功', !!(good.body && good.body.ok === true),
|
||||||
|
(good.body && (good.body.error || good.body.msg)) || '');
|
||||||
|
chk('导入人数为 2', !!(good.body && good.body.data && good.body.data.imported === 2),
|
||||||
|
good.body ? String(good.body.data && good.body.data.imported) : '');
|
||||||
|
chk('导入后 data/uploads 已清空', listUploads().length === 0, JSON.stringify(listUploads()));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 文件名不可枚举 ---');
|
||||||
|
const leaked = listUploads().filter(f => /^roster_\d{8}_\d{6}\./.test(f));
|
||||||
|
chk('不存在可枚举的 roster_时间戳 文件', leaked.length === 0, JSON.stringify(leaked));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
|
||||||
|
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
||||||
|
//
|
||||||
|
// 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||||||
|
// 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。
|
||||||
|
// 覆盖:
|
||||||
|
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
|
||||||
|
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
|
||||||
|
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||||||
|
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||||||
|
const http = require('http');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PHP_PORT = 18082;
|
||||||
|
const MOCK_PORT = 19911;
|
||||||
|
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||||||
|
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||||||
|
const DEBUG = !!process.env.NY_DEBUG;
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||||||
|
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
|
||||||
|
p = path.resolve(p);
|
||||||
|
process.env.NY_DATA = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const DATA = resolveDataDir();
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const UPLOADS = path.join(DATA, 'uploads');
|
||||||
|
|
||||||
|
const moved = [], copied = [];
|
||||||
|
for (const f of ['ny.db', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
const bak = p + '.nybak';
|
||||||
|
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cfgFile = path.join(DATA, 'config.json');
|
||||||
|
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||||
|
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||||||
|
api_prefix: '/api',
|
||||||
|
username: 'mock',
|
||||||
|
password: 'surveyking',
|
||||||
|
verify_ssl: false,
|
||||||
|
timeout: 10,
|
||||||
|
refresh_interval: 15,
|
||||||
|
page_size: 50,
|
||||||
|
include_temp_save: false,
|
||||||
|
dedupe: true,
|
||||||
|
name_keywords: '姓名,名字',
|
||||||
|
full_score: 100,
|
||||||
|
pass_rate: 60,
|
||||||
|
layout: 'default',
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
let mock = null, srv = null, cleanedUp = false;
|
||||||
|
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
killAll();
|
||||||
|
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||||||
|
const p = path.join(DATA, f);
|
||||||
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||||||
|
}
|
||||||
|
for (const [p, bak] of moved.concat(copied)) {
|
||||||
|
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||||||
|
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||||||
|
|
||||||
|
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||||||
|
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||||||
|
|
||||||
|
function waitPort(port, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
(function attempt() {
|
||||||
|
const s = net.connect({ host: '127.0.0.1', port });
|
||||||
|
const done = (ok) => { s.destroy(); resolve(ok); };
|
||||||
|
s.setTimeout(800, () => done(false));
|
||||||
|
s.on('connect', () => done(true));
|
||||||
|
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieJar = [];
|
||||||
|
let CSRF = '';
|
||||||
|
function baseHeaders() {
|
||||||
|
const h = {};
|
||||||
|
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function absorb(res) {
|
||||||
|
for (const sc of res.headers['set-cookie'] || []) {
|
||||||
|
const pair = sc.split(';')[0];
|
||||||
|
const name = pair.split('=')[0];
|
||||||
|
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||||
|
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function request(method, urlPath, headers, bodyBuf) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = Object.assign({}, headers || {});
|
||||||
|
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||||||
|
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
|
||||||
|
});
|
||||||
|
r.on('error', reject);
|
||||||
|
if (bodyBuf) r.write(bodyBuf);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const get = (p) => request('GET', p, baseHeaders());
|
||||||
|
function formPost(p, fields) {
|
||||||
|
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||||||
|
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||||||
|
}
|
||||||
|
function upload(urlPath, fields, file) {
|
||||||
|
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||||||
|
const parts = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
||||||
|
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||||||
|
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||||
|
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function chk(name, cond, extra) {
|
||||||
|
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||||||
|
const phpUp = await waitPort(PHP_PORT, 15000);
|
||||||
|
chk('mock 卷王服务已就绪', mockUp);
|
||||||
|
chk('PHP 内置服务器已就绪', phpUp);
|
||||||
|
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||||||
|
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||||
|
const ac = await get('/api.php?action=auth_check');
|
||||||
|
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||||||
|
const cg = await get('/api.php?action=config_get');
|
||||||
|
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chk('已登录并取到 CSRF token', CSRF !== '');
|
||||||
|
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
|
||||||
|
|
||||||
|
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
|
||||||
|
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
|
||||||
|
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||||||
|
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||||||
|
});
|
||||||
|
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
|
||||||
|
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
|
||||||
|
|
||||||
|
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
|
||||||
|
const d1 = await get('/api.php?action=data&projectId=p1');
|
||||||
|
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
|
||||||
|
const d1d = (d1.body && d1.body.data) || {};
|
||||||
|
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
|
||||||
|
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
|
||||||
|
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
|
||||||
|
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
|
||||||
|
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
|
||||||
|
|
||||||
|
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
|
||||||
|
const d2 = await get('/api.php?action=data&projectId=p2');
|
||||||
|
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
|
||||||
|
const d2d = (d2.body && d2.body.data) || {};
|
||||||
|
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
|
||||||
|
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
|
||||||
|
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
|
||||||
|
|
||||||
|
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
|
||||||
|
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
|
||||||
|
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
|
||||||
|
|
||||||
|
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
|
||||||
|
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
|
||||||
|
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||||||
|
cleanup();
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
})();
|
||||||
@@ -99,6 +99,168 @@ if ($parts !== []) {
|
|||||||
@unlink($manualOut);
|
@unlink($manualOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空
|
||||||
|
$mergedRows = [
|
||||||
|
['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'],
|
||||||
|
['2021001', '张三', '数学期中', '第1次', '90', '是'],
|
||||||
|
['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用
|
||||||
|
['', '', '英语期中', '第1次', '85', '是'],
|
||||||
|
];
|
||||||
|
$mOut = __DIR__ . '/_xlsxwriter_merge.xlsx';
|
||||||
|
$mBin = XlsxWriter::renderMulti([[
|
||||||
|
'name' => '每次考试',
|
||||||
|
'rows' => $mergedRows,
|
||||||
|
'widths' => [20, 14, 26, 10, 10, 8],
|
||||||
|
// A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃
|
||||||
|
'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]],
|
||||||
|
]]);
|
||||||
|
file_put_contents($mOut, $mBin);
|
||||||
|
chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK');
|
||||||
|
|
||||||
|
// 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读)
|
||||||
|
$mParts = [];
|
||||||
|
if (class_exists('ZipArchive')) {
|
||||||
|
$mz = new ZipArchive();
|
||||||
|
if ($mz->open($mOut) === true) {
|
||||||
|
for ($i = 0; $i < $mz->numFiles; $i++) {
|
||||||
|
$n = $mz->getNameIndex($i);
|
||||||
|
$mParts[$n] = $mz->getFromIndex($i);
|
||||||
|
}
|
||||||
|
$mz->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($mParts === []) {
|
||||||
|
try {
|
||||||
|
$rm = new ReflectionMethod('XlsxReader', 'readZip');
|
||||||
|
$rm->setAccessible(true);
|
||||||
|
$mParts = (array)$rm->invoke(null, $mOut);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$mParts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? '');
|
||||||
|
if ($sheetXml !== '') {
|
||||||
|
chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)',
|
||||||
|
strpos($sheetXml, '<mergeCells count="2">') !== false, '未找到 mergeCells count=2');
|
||||||
|
chk('合并区间 A2:A3 / B2:B3 正确',
|
||||||
|
strpos($sheetXml, '<mergeCell ref="A2:A3"/>') !== false
|
||||||
|
&& strpos($sheetXml, '<mergeCell ref="B2:B3"/>') !== false);
|
||||||
|
chk('mergeCells 位于 sheetData 之后',
|
||||||
|
strpos($sheetXml, '</sheetData><mergeCells') !== false);
|
||||||
|
chk('被合并的非首格只带样式、不带值(A3)',
|
||||||
|
strpos($sheetXml, '<c r="A3" s="2"/>') !== false, '未找到 <c r="A3" s="2"/>');
|
||||||
|
chk('区块首格仍然保留(A2 / B2 在)',
|
||||||
|
strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false);
|
||||||
|
chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false);
|
||||||
|
chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '<left style="thin">') !== false);
|
||||||
|
} else {
|
||||||
|
chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mBack = XlsxReader::read($mOut);
|
||||||
|
chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack));
|
||||||
|
chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中',
|
||||||
|
json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE));
|
||||||
|
@unlink($mOut);
|
||||||
|
|
||||||
|
/* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出,
|
||||||
|
并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */
|
||||||
|
$blockRows = [
|
||||||
|
['学号', '姓名', '第几次', '数学', '', '英语', ''],
|
||||||
|
['', '', '', '成绩', '', '成绩', ''],
|
||||||
|
['356001', '张三', '1', '112', '112'],
|
||||||
|
['', '', '2', '100', '100'],
|
||||||
|
['', '', '3', '136', '133'],
|
||||||
|
['', '', '合格次数', '3', '', '2', ''],
|
||||||
|
['356002', '李四', '1', '未考', '', '130'],
|
||||||
|
];
|
||||||
|
$bOut = __DIR__ . '/_xlsxwriter_block.xlsx';
|
||||||
|
$bBin = XlsxWriter::renderMulti([[
|
||||||
|
'name' => '每次考试',
|
||||||
|
'rows' => $blockRows,
|
||||||
|
'widths' => [20, 14, 9, 10, 11, 10, 11],
|
||||||
|
'merges' => [
|
||||||
|
[1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头
|
||||||
|
[3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三 学号/姓名/第几次
|
||||||
|
// 「第几次」不并入合格次数行,
|
||||||
|
// 否则 C6 的「合格次数」标签会被吞掉
|
||||||
|
[7, 4, 7, 5], // 李四 数学「未考」跨 2 列
|
||||||
|
],
|
||||||
|
'header_rows' => 2,
|
||||||
|
'cell_styles' => [
|
||||||
|
'5,4' => 'red', // 张三数学最高分 136 → 红字
|
||||||
|
'6,4' => 'green', // 张三数学合格次数 → 绿底
|
||||||
|
'6,6' => 'green', // 张三英语合格次数 → 绿底
|
||||||
|
'8,2' => 'nope', // 未知标记应被静默忽略
|
||||||
|
],
|
||||||
|
]]);
|
||||||
|
file_put_contents($bOut, $bBin);
|
||||||
|
chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK');
|
||||||
|
|
||||||
|
$bParts = [];
|
||||||
|
if (class_exists('ZipArchive')) {
|
||||||
|
$bz = new ZipArchive();
|
||||||
|
if ($bz->open($bOut) === true) {
|
||||||
|
for ($i = 0; $i < $bz->numFiles; $i++) {
|
||||||
|
$bn = $bz->getNameIndex($i);
|
||||||
|
$bParts[$bn] = $bz->getFromIndex($i);
|
||||||
|
}
|
||||||
|
$bz->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? '');
|
||||||
|
$bStyles = (string)($bParts['xl/styles.xml'] ?? '');
|
||||||
|
if ($bXml !== '') {
|
||||||
|
chk('第 2 行表头也用表头样式(s="1")',
|
||||||
|
strpos($bXml, '<c r="A2" s="1"/>') !== false && strpos($bXml, 'r="D2" s="1"') !== false);
|
||||||
|
chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false);
|
||||||
|
chk('表头合并区间齐备',
|
||||||
|
strpos($bXml, '<mergeCell ref="A1:A2"/>') !== false
|
||||||
|
&& strpos($bXml, '<mergeCell ref="D1:E1"/>') !== false
|
||||||
|
&& strpos($bXml, '<mergeCell ref="F1:G1"/>') !== false
|
||||||
|
&& strpos($bXml, '<mergeCell ref="D7:E7"/>') !== false);
|
||||||
|
chk('mergeCells 计数 = 9', strpos($bXml, '<mergeCells count="9">') !== false);
|
||||||
|
chk('「第几次」不并入合格次数行(C6 仍带值)',
|
||||||
|
strpos($bXml, '<c r="C6" s="2" t="s"><v>16</v></c>') !== false);
|
||||||
|
|
||||||
|
// ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ----
|
||||||
|
chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '<c r="D5" s="3"') !== false);
|
||||||
|
chk('合格次数达标用绿底样式(D6 s="4")', strpos($bXml, '<c r="D6" s="4"') !== false);
|
||||||
|
chk('合格次数达标用绿底样式(F6 s="4")', strpos($bXml, '<c r="F6" s="4"') !== false);
|
||||||
|
chk('未标记格仍用普通文本样式(D3 s="2")', strpos($bXml, '<c r="D3" s="2"') !== false);
|
||||||
|
|
||||||
|
// 样式表里必须真的存在红字字体与绿色填充,否则 Excel 打开是一片黑白
|
||||||
|
chk('styles 含红色字体(FFD0021B)', strpos($bStyles, 'FFD0021B') !== false);
|
||||||
|
chk('styles 含绿色填充(FF63C384)', strpos($bStyles, 'FF63C384') !== false);
|
||||||
|
chk('cellXfs 数量 = 6', strpos($bStyles, '<cellXfs count="6">') !== false);
|
||||||
|
|
||||||
|
// XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事
|
||||||
|
if (class_exists('DOMDocument')) {
|
||||||
|
$okXml = true;
|
||||||
|
foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) {
|
||||||
|
$doc = new DOMDocument();
|
||||||
|
if ($xml === '' || !@$doc->loadXML($xml)) {
|
||||||
|
$okXml = false;
|
||||||
|
chk('XML 可解析:' . $part, false, '解析失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($okXml) {
|
||||||
|
chk('XML 可解析(sheet / styles)', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true);
|
||||||
|
}
|
||||||
|
$bBack = XlsxReader::read($bOut);
|
||||||
|
chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack));
|
||||||
|
chk('两行表头表内容正确',
|
||||||
|
($bBack[2][0] ?? '') === '356001'
|
||||||
|
&& ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉
|
||||||
|
&& ($bBack[6][3] ?? '') === '未考'
|
||||||
|
&& ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133',
|
||||||
|
json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE));
|
||||||
|
@unlink($bOut);
|
||||||
|
|
||||||
@unlink($out);
|
@unlink($out);
|
||||||
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
|
echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL;
|
||||||
exit($fail === 0 ? 0 : 1);
|
exit($fail === 0 ? 0 : 1);
|
||||||
|
|||||||