完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接 - 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey) - 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加 - 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则 - 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致 - 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放 - 测试:新增 test-whitelist.js;run-all.sh 全绿 132
This commit is contained in:
@@ -103,15 +103,4 @@ final class DataCache
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
|
||||
public static function clear(): int
|
||||
{
|
||||
$n = 0;
|
||||
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
|
||||
if (@unlink($f)) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
}
|
||||
|
||||
+86
-22
@@ -30,9 +30,15 @@ class DataService
|
||||
return $out;
|
||||
}
|
||||
|
||||
// children 在根或子节点
|
||||
// 不同卷王版本题目可能挂在 children / questions / list 下,统一兼容
|
||||
$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'] : '';
|
||||
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
||||
@@ -138,26 +144,15 @@ class DataService
|
||||
}
|
||||
}
|
||||
|
||||
$students = Roster::all();
|
||||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
||||
// 基础名单(全名单):用于答卷匹配,不受白名单影响
|
||||
$rosterAll = Roster::all();
|
||||
if ($exempt !== []) {
|
||||
$students = array_values(array_filter(
|
||||
$students,
|
||||
$rosterAll = array_values(array_filter(
|
||||
$rosterAll,
|
||||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||
));
|
||||
}
|
||||
// 姓名 -> 学号 / 班级(规范化)
|
||||
$studentInfo = [];
|
||||
foreach ($students as $s) {
|
||||
$k = ny_norm_name((string)$s['name']);
|
||||
if ($k !== '') {
|
||||
$studentInfo[$k] = [
|
||||
'sid' => (string)$s['sid'],
|
||||
'cls' => (string)($s['cls'] ?? ''),
|
||||
'grp' => (string)($s['grp'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
|
||||
|
||||
$project = $client->getProject($projectId);
|
||||
$survey = $project['survey'] ?? null;
|
||||
@@ -167,6 +162,73 @@ class DataService
|
||||
$survey = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// 卷内「答题白名单」:决定「应考名单」(缺/待考名单来源)。
|
||||
// 未设置白名单时「按现在的方案显示」(使用全名单)。
|
||||
$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);
|
||||
|
||||
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
||||
@@ -218,7 +280,7 @@ class DataService
|
||||
continue;
|
||||
}
|
||||
$v = ny_flatten_value($answerMap[$qid]);
|
||||
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
|
||||
if ($v !== '' && isset($studentInfo[ny_norm_name($v)])) {
|
||||
$name = $v;
|
||||
break;
|
||||
}
|
||||
@@ -227,7 +289,7 @@ class DataService
|
||||
if ($name === '' && $questions === []) {
|
||||
foreach ($answerMap as $v) {
|
||||
$s = ny_flatten_value($v);
|
||||
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
|
||||
if ($s !== '' && isset($studentInfo[ny_norm_name($s)])) {
|
||||
$name = $s;
|
||||
break;
|
||||
}
|
||||
@@ -252,8 +314,8 @@ class DataService
|
||||
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
||||
}
|
||||
|
||||
// 考试时长
|
||||
$ai = $a['metaInfo']['answerInfo'] ?? [];
|
||||
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
|
||||
$ai = ($a['metaInfo'] ?? [])['answerInfo'] ?? [];
|
||||
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
||||
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
||||
$dur = null;
|
||||
@@ -371,6 +433,8 @@ class DataService
|
||||
'stats' => $stats,
|
||||
'roster' => $students,
|
||||
'roster_count' => count($students),
|
||||
'whitelist_driven' => $useWhitelist,
|
||||
'whitelist_total' => $whitelistTotal,
|
||||
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||
'project_cfg' => [
|
||||
'full_score' => $pCfg['full_score'] ?? null,
|
||||
|
||||
+9
-70
@@ -56,17 +56,6 @@ class Db
|
||||
return self::$err;
|
||||
}
|
||||
|
||||
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
|
||||
public static function params(): array
|
||||
{
|
||||
return ['driver' => 'sqlite', 'file' => self::dbFile()];
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return true; // SQLite 无需外部配置
|
||||
}
|
||||
|
||||
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||
public static function connect(array $p = []): PDO
|
||||
{
|
||||
@@ -180,25 +169,6 @@ class Db
|
||||
|
||||
// ---------------- 设置 ----------------
|
||||
|
||||
public static function settingsAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT k, v FROM {$p}settings");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[(string)$r['k']] = (string)$r['v'];
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
||||
public static function settingsSet(string $k, string $v): void
|
||||
@@ -265,12 +235,16 @@ class Db
|
||||
}
|
||||
}
|
||||
|
||||
/** 全量替换名单(roster_save 语义) */
|
||||
public static function rosterReplace(array $students): void
|
||||
/**
|
||||
* 全量替换名单(roster_save 语义)
|
||||
* @return bool 成功 true;DB 不可用或事务失败 false(失败原因见 lastError)
|
||||
*/
|
||||
public static function rosterReplace(array $students): bool
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
self::$err = '数据库未连接';
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
@@ -296,11 +270,13 @@ class Db
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
self::$err = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,29 +297,6 @@ class Db
|
||||
|
||||
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||
|
||||
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
|
||||
public static function projectConfigAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
|
||||
if (is_array($j)) {
|
||||
$out[(string)$r['project_id']] = $j;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array 单卷配置(缺省返回空结构) */
|
||||
public static function projectConfigGet(string $projectId): array
|
||||
@@ -394,18 +347,4 @@ class Db
|
||||
}
|
||||
}
|
||||
|
||||
public static function projectConfigDelete(string $projectId): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
|
||||
$st->execute([$projectId]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-27
@@ -110,9 +110,23 @@ class Roster
|
||||
'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()) {
|
||||
Db::rosterReplace($clean);
|
||||
return true;
|
||||
return Db::rosterReplace($clean);
|
||||
}
|
||||
return ny_write_json(self::file(), [
|
||||
'students' => $clean,
|
||||
@@ -157,31 +171,6 @@ class Roster
|
||||
return $mt . ':' . $sz . ':' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
|
||||
*/
|
||||
public static function ensureSeeded(): bool
|
||||
{
|
||||
if (self::count() > 0) {
|
||||
return false;
|
||||
}
|
||||
$candidates = [
|
||||
dirname(NY_ROOT) . '/导入模板.xlsx',
|
||||
NY_ROOT . '/导入模板.xlsx',
|
||||
NY_DATA . '/导入模板.xlsx',
|
||||
];
|
||||
foreach ($candidates as $f) {
|
||||
if (is_file($f)) {
|
||||
try {
|
||||
$res = self::importFile($f, 'xlsx');
|
||||
return $res['count'] > 0;
|
||||
} catch (Throwable $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析上传文件 -> 学生数组
|
||||
|
||||
@@ -483,6 +483,100 @@ class SurveyKingClient
|
||||
return is_array($r['data']) ? $r['data'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取问卷设置的「答题白名单」成员列表(待考名单来源)
|
||||
*
|
||||
* 卷王不同版本字段形状不一,这里尽量兼容:
|
||||
* - survey.surveySetting.whiteLists:{ 题ID: [值,...] }(社区版常见)
|
||||
* - survey.surveySetting.whiteList:{ 题ID: [值,...] }
|
||||
* - 也可能直接是字符串或嵌套数组
|
||||
* 返回去重后的纯值数组(顺序保持首次出现),值本身通常是学号 / 姓名 / 登陆账号。
|
||||
* 未设置白名单返回空数组(上层据此「按现在的方案显示」)。
|
||||
*
|
||||
* @param string $projectId 问卷 ID
|
||||
* @param array|null $survey 可传入已解析的 survey 对象,避免重复请求 API(DataService::build 已取过 project)
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function getWhitelist(string $projectId, ?array $survey = null): array
|
||||
{
|
||||
if ($survey === null) {
|
||||
$proj = $this->getProject($projectId);
|
||||
if (!is_array($proj)) {
|
||||
return [];
|
||||
}
|
||||
$survey = $proj['survey'] ?? null;
|
||||
if (is_string($survey) && $survey !== '') {
|
||||
$decoded = json_decode($survey, true);
|
||||
if (is_array($decoded)) {
|
||||
$survey = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兼容 { surveySetting: ... } 与 { survey: { surveySetting: ... } } 两种结构,
|
||||
// 也允许白名单直接挂在 survey 顶层(whiteLists / whiteList / whitelist)。
|
||||
$setting = null;
|
||||
if (is_array($survey)) {
|
||||
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 [];
|
||||
}
|
||||
return self::parseWhitelist($setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 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
|
||||
{
|
||||
|
||||
+10
-4
@@ -15,6 +15,8 @@ final class XlsxWriter
|
||||
private array $strIndex = [];
|
||||
/** @var array<int,string> 索引 => 共享字符串 */
|
||||
private array $strings = [];
|
||||
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
|
||||
private array $strCount = [];
|
||||
/** @var array<int,array{name:string,rows:array,widths:array}> 工作表(按输出顺序) */
|
||||
private array $sheets = [];
|
||||
|
||||
@@ -156,8 +158,11 @@ final class XlsxWriter
|
||||
$i = count($this->strings);
|
||||
$this->strIndex[$s] = $i;
|
||||
$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, array $widths = []): string
|
||||
@@ -287,14 +292,15 @@ final class XlsxWriter
|
||||
|
||||
private function sharedStringsXml(): string
|
||||
{
|
||||
$count = count($this->strings);
|
||||
$unique = count($this->strings);
|
||||
$total = array_sum($this->strCount);
|
||||
$body = '';
|
||||
foreach ($this->strings as $s) {
|
||||
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
|
||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $count .
|
||||
'" uniqueCount="' . $count . '">' . $body . '</sst>';
|
||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
|
||||
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
|
||||
}
|
||||
|
||||
private function coreXml(string $title): string
|
||||
|
||||
Reference in New Issue
Block a user