- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
312 lines
11 KiB
PHP
312 lines
11 KiB
PHP
<?php
|
||
/**
|
||
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
class DataService
|
||
{
|
||
/** 容器型题目(分页、题组、说明)不参与取值 */
|
||
private const CONTAINER_TYPES = [
|
||
'pagination', 'page', 'group', 'survey', 'divider', 'tree',
|
||
];
|
||
|
||
/** 题目标题可能是富文本,去掉标签便于展示与匹配 */
|
||
private static function cleanTitle(string $t): string
|
||
{
|
||
$t = strip_tags($t);
|
||
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
return trim(preg_replace('/\s+/u', ' ', $t) ?? '');
|
||
}
|
||
|
||
/**
|
||
* 展平题目树
|
||
* @return array<int,array{id:string,title:string,type:string,path:string}>
|
||
*/
|
||
public static function flattenQuestions(mixed $node, string $path = ''): array
|
||
{
|
||
$out = [];
|
||
if (!is_array($node)) {
|
||
return $out;
|
||
}
|
||
|
||
// children 在根或子节点
|
||
$self = $node;
|
||
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
|
||
|
||
$id = isset($self['id']) ? (string)$self['id'] : '';
|
||
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
|
||
$type = isset($self['type']) ? (string)$self['type'] : '';
|
||
|
||
$isRoot = ($path === '');
|
||
if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) {
|
||
$out[] = [
|
||
'id' => $id,
|
||
'title' => $title !== '' ? $title : $id,
|
||
'type' => $type,
|
||
'path' => $path,
|
||
];
|
||
}
|
||
|
||
if ($children !== null) {
|
||
foreach ($children as $idx => $child) {
|
||
$subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1);
|
||
foreach (self::flattenQuestions($child, $subPath) as $q) {
|
||
$out[] = $q;
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/** 根据关键词猜测"姓名"题 */
|
||
public static function guessNameQuestion(array $questions, string $keywords): array
|
||
{
|
||
$kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: [])));
|
||
$candidates = [];
|
||
foreach ($questions as $q) {
|
||
$t = (string)$q['title'];
|
||
foreach ($kws as $kw) {
|
||
if ($kw !== '' && mb_stripos($t, $kw) !== false) {
|
||
$candidates[] = $q;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return $candidates;
|
||
}
|
||
|
||
/**
|
||
* 组装结果
|
||
*/
|
||
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
||
{
|
||
$client = new SurveyKingClient($cfg);
|
||
|
||
$students = Roster::all();
|
||
// 姓名 -> 学号 / 班级(规范化)
|
||
$studentInfo = [];
|
||
foreach ($students as $s) {
|
||
$k = ny_norm_name((string)$s['name']);
|
||
if ($k !== '') {
|
||
$studentInfo[$k] = [
|
||
'sid' => (string)$s['sid'],
|
||
'cls' => (string)($s['cls'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
$project = $client->getProject($projectId);
|
||
$survey = $project['survey'] ?? null;
|
||
if (is_string($survey) && $survey !== '') {
|
||
$decoded = json_decode($survey, true);
|
||
if (is_array($decoded)) {
|
||
$survey = $decoded;
|
||
}
|
||
}
|
||
$questions = self::flattenQuestions($survey);
|
||
|
||
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
|
||
$nameCandidates = self::guessNameQuestion($questions, $keywords);
|
||
$autoQid = $nameCandidates[0]['id'] ?? '';
|
||
|
||
// 指定优先
|
||
$pickedQid = $nameQid !== '' ? $nameQid : $autoQid;
|
||
$candidateIds = [];
|
||
if ($pickedQid !== '') {
|
||
$candidateIds[] = $pickedQid;
|
||
}
|
||
foreach ($nameCandidates as $q) {
|
||
if (!in_array($q['id'], $candidateIds, true)) {
|
||
$candidateIds[] = $q['id'];
|
||
}
|
||
}
|
||
|
||
$answers = $client->listAnswers($projectId);
|
||
|
||
$includeTemp = !empty($cfg['include_temp_save']);
|
||
$dedupe = !empty($cfg['dedupe']);
|
||
|
||
$rows = [];
|
||
$unmatched = [];
|
||
$seenSid = [];
|
||
|
||
foreach ($answers as $a) {
|
||
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
|
||
if (!$includeTemp && $tempSave === 0) {
|
||
continue; // 跳过暂存
|
||
}
|
||
$answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : [];
|
||
|
||
// 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名
|
||
$name = '';
|
||
foreach ($candidateIds as $qid) {
|
||
if (isset($answerMap[$qid])) {
|
||
$v = ny_flatten_value($answerMap[$qid]);
|
||
if ($v !== '') {
|
||
$name = $v;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($name === '') {
|
||
foreach ($questions as $q) {
|
||
$qid = (string)$q['id'];
|
||
if (!array_key_exists($qid, $answerMap)) {
|
||
continue;
|
||
}
|
||
$v = ny_flatten_value($answerMap[$qid]);
|
||
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
|
||
$name = $v;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($name === '' && $questions === []) {
|
||
foreach ($answerMap as $v) {
|
||
$s = ny_flatten_value($v);
|
||
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
|
||
$name = $s;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($name === '') {
|
||
$name = ny_str($a['createByName'] ?? '');
|
||
}
|
||
if ($name === '') {
|
||
$name = '(未填写姓名)';
|
||
}
|
||
|
||
// 成绩
|
||
$score = null;
|
||
if (isset($a['examScore']) && is_numeric($a['examScore'])) {
|
||
$score = (float)$a['examScore'];
|
||
}
|
||
|
||
// 提交时间
|
||
$ts = ny_to_timestamp($a['createAt'] ?? null);
|
||
if ($ts === 0) {
|
||
$ts = ny_to_timestamp($a['updateAt'] ?? null);
|
||
}
|
||
|
||
// 考试时长
|
||
$ai = $a['metaInfo']['answerInfo'] ?? [];
|
||
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
|
||
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
|
||
$dur = null;
|
||
if ($start > 0 && $end > $start) {
|
||
$dur = (int)round(($end - $start) / 1000);
|
||
}
|
||
|
||
$key = ny_norm_name($name);
|
||
$info = $studentInfo[$key] ?? null;
|
||
$sid = $info['sid'] ?? '';
|
||
$cls = $info['cls'] ?? '';
|
||
|
||
$row = [
|
||
'sid' => $sid,
|
||
'name' => $name,
|
||
'cls' => $cls,
|
||
'score' => $score,
|
||
'submit_ts' => $ts,
|
||
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
|
||
'duration' => $dur,
|
||
'duration_txt'=> ny_format_duration($dur),
|
||
'answer_id' => ny_str($a['id'] ?? ''),
|
||
'matched' => $sid !== '',
|
||
'temp_save' => $tempSave === 0,
|
||
];
|
||
|
||
if ($sid === '') {
|
||
$unmatched[] = $row;
|
||
continue;
|
||
}
|
||
|
||
if ($dedupe && isset($seenSid[$sid])) {
|
||
$idx = $seenSid[$sid];
|
||
// 保留提交时间最新的一份
|
||
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
|
||
$rows[$idx] = $row;
|
||
$seenSid[$sid] = $idx;
|
||
}
|
||
continue;
|
||
}
|
||
$seenSid[$sid] = count($rows);
|
||
$rows[] = $row;
|
||
}
|
||
|
||
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
|
||
usort($rows, static function (array $x, array $y): int {
|
||
$d = ($y['submit_ts'] ?? 0) <=> ($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);
|
||
});
|
||
|
||
// 缺考名单
|
||
$submittedSids = [];
|
||
foreach ($rows as $r) {
|
||
$submittedSids[(string)$r['sid']] = true;
|
||
}
|
||
$absent = [];
|
||
foreach ($students as $s) {
|
||
if (!isset($submittedSids[(string)$s['sid']])) {
|
||
$absent[] = [
|
||
'sid' => (string)$s['sid'],
|
||
'name' => (string)$s['name'],
|
||
'cls' => (string)($s['cls'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
usort($absent, static function (array $x, array $y): int {
|
||
$a = (string)$x['sid'];
|
||
$b = (string)$y['sid'];
|
||
if (is_numeric($a) && is_numeric($b)) {
|
||
return $a <=> $b;
|
||
}
|
||
return strnatcmp($a, $b);
|
||
});
|
||
|
||
// 统计
|
||
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null));
|
||
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null));
|
||
$stats = [
|
||
'roster_total' => count($students),
|
||
'submitted' => count($rows),
|
||
'absent' => count($absent),
|
||
'unmatched' => count($unmatched),
|
||
'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0,
|
||
'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null,
|
||
'max_score' => $scores !== [] ? max($scores) : null,
|
||
'min_score' => $scores !== [] ? min($scores) : null,
|
||
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
|
||
'answer_total' => count($answers),
|
||
];
|
||
|
||
return [
|
||
'project' => [
|
||
'id' => ny_str($project['id'] ?? $projectId),
|
||
'name' => ny_str($project['name'] ?? ''),
|
||
'mode' => ny_str($project['mode'] ?? ''),
|
||
],
|
||
'questions' => $questions,
|
||
'name_qid' => $pickedQid,
|
||
'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates),
|
||
'rows' => $rows,
|
||
'absent' => $absent,
|
||
'unmatched' => $unmatched,
|
||
'stats' => $stats,
|
||
'roster' => $students,
|
||
'roster_count' => count($students),
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
}
|