diff --git a/dashboard.php b/dashboard.php
index 6608eb3..598e573 100644
--- a/dashboard.php
+++ b/dashboard.php
@@ -58,7 +58,6 @@ if (!ny_is_auth()) {
总人数-
已提交-
缺考-
- 提交率-
平均分-
合格率(≥60%满分)-
@@ -343,12 +342,12 @@ document.addEventListener('keydown', e => {
/* 占位卡片配色:与真实看板卡片(renderBoard)一一对应,保证空态左侧色条一致 */
const PLACEHOLDER_ACCENT = {
'总人数': 'accent-blue', '已提交': 'accent-green', '缺考': 'accent-orange',
- '提交率': 'accent-purple', '平均分': 'accent-teal', '合格率': 'accent-indigo'
+ '平均分': 'accent-teal', '合格率': 'accent-indigo'
};
function renderPlaceholderBoard(){
const cards = [
['总人数','-'],['已提交','-'],['缺考','-'],
- ['提交率','-'],['平均分','-'],['合格率','-']
+ ['平均分','-'],['合格率','-']
];
$('#boardSummary').innerHTML = cards.map(c =>
// 占位卡片与真实看板卡片顺序、配色一致(accent-* 决定左侧色条),避免空态蓝条与真实卡片不一致
@@ -421,13 +420,11 @@ function renderBoard(){
'总 ' + dv(c.total, 0) + ' / 已交 ' + dv(c.submitted, 0) + ' / 缺考 ' + dv(c.absent, 0) +
(totalAttempts ? ' · 共 ' + totalAttempts + ' 次提交' : '');
- // 6 卡汇总
- const color = rateColor(c.rate || 0);
+ // 5 卡汇总(提交率卡片已移除)
$('#boardSummary').innerHTML =
'总人数' + dv(c.total, 0) + '
' +
'已提交' + dv(c.submitted, 0) + '
' +
'缺考' + dv(c.absent, 0) + '
' +
- '提交率' + dv(c.rate, 0) + '%
' +
'平均分' + dv(c.avg) + '
' +
'合格率(≥' + (DASH && DASH.pass_rate != null ? DASH.pass_rate : 60) + '%满分)' + pv(c.pass_rate) + '
';
diff --git a/lib/DataService.php b/lib/DataService.php
index 8b30ab1..f929dd4 100644
--- a/lib/DataService.php
+++ b/lib/DataService.php
@@ -163,8 +163,10 @@ class DataService
}
}
- // 卷内「答题白名单」:决定「应考名单」(缺/待考名单来源)。
- // 未设置白名单时「按现在的方案显示」(使用全名单)。
+ // 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
+ // 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
+ // 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
+ // 未配置受访者名单时「按现在的方案显示」(使用全名单)。
$paperWhitelist = [];
try {
$paperWhitelist = $client->getWhitelist($projectId, $survey);
diff --git a/lib/SurveyKingClient.php b/lib/SurveyKingClient.php
index 13c1ed8..903c3e2 100644
--- a/lib/SurveyKingClient.php
+++ b/lib/SurveyKingClient.php
@@ -484,38 +484,78 @@ class SurveyKingClient
}
/**
- * 取问卷设置的「答题白名单」成员列表(待考名单来源)
+ * 取「应考名单」来源(待考 / 缺考名单由此驱动)。
*
- * 卷王不同版本字段形状不一,这里尽量兼容:
- * - survey.surveySetting.whiteLists:{ 题ID: [值,...] }(社区版常见)
- * - survey.surveySetting.whiteList:{ 题ID: [值,...] }
- * - 也可能直接是字符串或嵌套数组
- * 返回去重后的纯值数组(顺序保持首次出现),值本身通常是学号 / 姓名 / 登陆账号。
- * 未设置白名单返回空数组(上层据此「按现在的方案显示」)。
+ * 卷王真实模型(社区版 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 对象,避免重复请求 API(DataService::build 已取过 project)
- * @return array
+ * @param array|null $survey 已解析的 survey(仅极老版本白名单兜底用,新模型不依赖它)
+ * @return array 应考成员标识:优先姓名(便于与本地名单按姓名匹配),其次登陆账号 / 学号
*/
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;
+ // 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;
+ }
}
- // 兼容 { surveySetting: ... } 与 { survey: { surveySetting: ... } } 两种结构,
- // 也允许白名单直接挂在 survey 顶层(whiteLists / whiteList / whitelist)。
- $setting = null;
+
+ // 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'])) {
@@ -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
+ */
+ 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;
}
/**
diff --git a/tools/mock-server.js b/tools/mock-server.js
index 2cebab3..0b3cf87 100644
--- a/tools/mock-server.js
+++ b/tools/mock-server.js
@@ -43,10 +43,7 @@ answers.push({
const SURVEY = {
id: 'root', title: '高一数学月考', type: 'survey',
- surveySetting: {
- // 答题白名单:待考名单来源(键为题目 ID,值为允许的答案列表;这里用学号作标识)
- whiteLists: { q1: ['90001', '90002', '90003', '90004', '90005'] },
- },
+ surveySetting: {},
children: [
{ id: 'pg1', type: 'pagination', title: '基本信息', children: [
{ id: 'q1', type: 'text', title: '姓名
' },
@@ -63,6 +60,19 @@ const PROJECTS = [
{ 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) {
res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {}));
res.end(JSON.stringify(obj));
@@ -132,6 +142,11 @@ const server = http.createServer((req, res) => {
const slice = all.slice((cur - 1) * size, cur * size);
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 });
});
diff --git a/tools/test-whitelist.js b/tools/test-whitelist.js
index 57b106b..cafb721 100644
--- a/tools/test-whitelist.js
+++ b/tools/test-whitelist.js
@@ -1,9 +1,11 @@
-// e2e: 卷内「答题白名单」自动驱动缺/待考名单(DataService::build)
+// 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 设置了卷内白名单 → 应考名单恰好为白名单成员,缺考仅来自白名单(不含白名单外成员)
-// 2) p2 未设置白名单 → 按现状(全名单)显示,白名单外成员仍在应考/缺考中
+// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
+// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
const http = require('http');