修复考试白名单驱动缺/待考名单:改用卷王项目参与者(Project Partners)真实模型
旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。 dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
This commit is contained in:
+3
-6
@@ -58,7 +58,6 @@ if (!ny_is_auth()) {
|
|||||||
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
|
<div class="card accent-blue"><span>总人数</span><b id="bTotal">-</b></div>
|
||||||
<div class="card ok accent-green"><span>已提交</span><b id="bSubmit">-</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="bAbsent">-</b></div>
|
<div class="card warn accent-orange"><span>缺考</span><b id="bAbsent">-</b></div>
|
||||||
<div class="card accent-purple"><span>提交率</span><b id="bRate">-</b></div>
|
|
||||||
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</b></div>
|
<div class="card accent-teal"><span>平均分</span><b id="bAvg">-</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>合格率<span id="bPassLabel">(≥60%满分)</span></span><b id="bPass">-</b></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -343,12 +342,12 @@ document.addEventListener('keydown', e => {
|
|||||||
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
|
||||||
const PLACEHOLDER_ACCENT = {
|
const PLACEHOLDER_ACCENT = {
|
||||||
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
|
||||||
'提交率': 'accent-purple', '平均分': 'accent-teal', '合格率': 'accent-indigo'
|
'平均分': 'accent-teal', '合格率': 'accent-indigo'
|
||||||
};
|
};
|
||||||
function renderPlaceholderBoard(){
|
function renderPlaceholderBoard(){
|
||||||
const cards = [
|
const cards = [
|
||||||
['总人数','-'],['已提交','-'],['缺考','-'],
|
['总人数','-'],['已提交','-'],['缺考','-'],
|
||||||
['提交率','-'],['平均分','-'],['合格率','-']
|
['平均分','-'],['合格率','-']
|
||||||
];
|
];
|
||||||
$('#boardSummary').innerHTML = cards.map(c =>
|
$('#boardSummary').innerHTML = cards.map(c =>
|
||||||
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
|
||||||
@@ -421,13 +420,11 @@ function renderBoard(){
|
|||||||
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
|
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
|
||||||
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
|
||||||
|
|
||||||
// 6 卡汇总
|
// 5 卡汇总(提交率卡片已移除)
|
||||||
const color = rateColor(c.rate || 0);
|
|
||||||
$('#boardSummary').innerHTML =
|
$('#boardSummary').innerHTML =
|
||||||
'<div class="card accent-blue"><span>总人数</span><b>' + dv(c.total, 0) + '</b></div>' +
|
'<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 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 warn accent-orange"><span>缺考</span><b>' + dv(c.absent, 0) + '</b></div>' +
|
||||||
'<div class="card accent-purple"><span>提交率</span><b style="color:' + color + '">' + dv(c.rate, 0) + '%</b></div>' +
|
|
||||||
'<div class="card accent-teal"><span>平均分</span><b>' + dv(c.avg) + '</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>';
|
'<div class="card accent-indigo"><span>合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)</span><b>' + pv(c.pass_rate) + '</b></div>';
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -163,8 +163,10 @@ class DataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 卷内「答题白名单」:决定「应考名单」(缺/待考名单来源)。
|
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
|
||||||
// 未设置白名单时「按现在的方案显示」(使用全名单)。
|
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||||||
|
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
|
||||||
|
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
|
||||||
$paperWhitelist = [];
|
$paperWhitelist = [];
|
||||||
try {
|
try {
|
||||||
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
||||||
|
|||||||
+102
-25
@@ -484,38 +484,78 @@ class SurveyKingClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取问卷设置的「答题白名单」成员列表(待考名单来源)
|
* 取「应考名单」来源(待考 / 缺考名单由此驱动)。
|
||||||
*
|
*
|
||||||
* 卷王不同版本字段形状不一,这里尽量兼容:
|
* 卷王真实模型(社区版 v1.13 与开源版一致):
|
||||||
* - survey.surveySetting.whiteLists:{ 题ID: [值,...] }(社区版常见)
|
* 限制谁可以答卷的是项目「参与者(受访者)」—— t_project_partner 中
|
||||||
* - survey.surveySetting.whiteList:{ 题ID: [值,...] }
|
* 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 string $projectId 问卷 ID
|
||||||
* @param array|null $survey 可传入已解析的 survey 对象,避免重复请求 API(DataService::build 已取过 project)
|
* @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
|
||||||
* @return array<int,string>
|
* @return array<int,string> 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
|
||||||
*/
|
*/
|
||||||
public function getWhitelist(string $projectId, ?array $survey = null): array
|
public function getWhitelist(string $projectId, ?array $survey = null): array
|
||||||
{
|
{
|
||||||
if ($survey === null) {
|
// 1) 真实模型:项目「受访者」参与者 = 应考名单
|
||||||
$proj = $this->getProject($projectId);
|
$partners = [];
|
||||||
if (!is_array($proj)) {
|
try {
|
||||||
return [];
|
$partners = $this->getRespondentPartners($projectId);
|
||||||
}
|
} catch (Throwable $e) {
|
||||||
$survey = $proj['survey'] ?? null;
|
$this->log[] = 'getRespondentPartners 失败(回退白名单兜底):' . $e->getMessage();
|
||||||
if (is_string($survey) && $survey !== '') {
|
}
|
||||||
$decoded = json_decode($survey, true);
|
|
||||||
if (is_array($decoded)) {
|
if ($partners !== []) {
|
||||||
$survey = $decoded;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 兼容 { surveySetting: ... } 与 { survey: { surveySetting: ... } } 两种结构,
|
|
||||||
// 也允许白名单直接挂在 survey 顶层(whiteLists / whiteList / whitelist)。
|
// 2) 兜底:极老 / 定制版本可能把应考名单放在 surveySetting.whiteLists(实为题目选项白名单)
|
||||||
$setting = null;
|
|
||||||
if (is_array($survey)) {
|
if (is_array($survey)) {
|
||||||
|
$setting = null;
|
||||||
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
|
if (isset($survey['surveySetting']) && is_array($survey['surveySetting'])) {
|
||||||
$setting = $survey['surveySetting'];
|
$setting = $survey['surveySetting'];
|
||||||
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
|
} elseif (isset($survey['survey']) && is_array($survey['survey']) && isset($survey['survey']['surveySetting'])) {
|
||||||
@@ -529,11 +569,48 @@ class SurveyKingClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (is_array($setting)) {
|
||||||
|
return self::parseWhitelist($setting);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!is_array($setting)) {
|
return [];
|
||||||
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 self::parseWhitelist($setting);
|
return $all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+19
-4
@@ -43,10 +43,7 @@ answers.push({
|
|||||||
|
|
||||||
const SURVEY = {
|
const SURVEY = {
|
||||||
id: 'root', title: '高一数学月考', type: 'survey',
|
id: 'root', title: '高一数学月考', type: 'survey',
|
||||||
surveySetting: {
|
surveySetting: {},
|
||||||
// 答题白名单:待考名单来源(键为题目 ID,值为允许的答案列表;这里用学号作标识)
|
|
||||||
whiteLists: { q1: ['90001', '90002', '90003', '90004', '90005'] },
|
|
||||||
},
|
|
||||||
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>' },
|
||||||
@@ -63,6 +60,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));
|
||||||
@@ -132,6 +142,11 @@ 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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// e2e: 卷内「答题白名单」自动驱动缺/待考名单(DataService::build)
|
// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
|
||||||
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
// 运行: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 设置了卷内白名单 → 应考名单恰好为白名单成员,缺考仅来自白名单(不含白名单外成员)
|
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
|
||||||
// 2) p2 未设置白名单 → 按现状(全名单)显示,白名单外成员仍在应考/缺考中
|
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
|
||||||
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||||||
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|||||||
Reference in New Issue
Block a user