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');