feat: 登录门认证 + 每套试卷独立满分等增强
- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据 - 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算) - 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤) - 移除最高/最低分获得者显示 - 平均用时不换行(避免“秒”字换行) - 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书 - 统计卡片含合格率(≥60% 满分) - .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
This commit is contained in:
+239
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* NY 问卷考试系统数据平台 - 引导 / 公共函数
|
||||
* PHP >= 8.0
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
const NY_APP_NAME = 'NY问卷考试系统数据平台';
|
||||
const NY_VERSION = '1.0.0';
|
||||
|
||||
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
||||
define('NY_DATA', NY_ROOT . '/data');
|
||||
|
||||
// ---------------- 环境预检 ----------------
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<h2>环境不满足</h2><p>本平台需要 <b>PHP 8.0 及以上</b>,当前版本:' . PHP_VERSION . '</p>';
|
||||
exit;
|
||||
}
|
||||
$missing = [];
|
||||
foreach (['curl' => 'curl', 'json' => 'json', 'mbstring' => 'mbstring'] as $fn => $ext) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$missing[] = $ext;
|
||||
}
|
||||
}
|
||||
if ($missing !== []) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<h2>缺少 PHP 扩展</h2><p>请在 php.ini 中启用:<b>' . implode('、', $missing) . '</b></p>'
|
||||
. '<p>Windows:去掉 <code>extension=' . $missing[0] . '</code> 前面的分号并重启服务。'
|
||||
. '(<code>zip</code> 扩展可选,未启用时会用内置解析器读取 xlsx)</p>';
|
||||
exit;
|
||||
}
|
||||
|
||||
date_default_timezone_set('Asia/Shanghai');
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
// ---------------- 目录初始化 ----------------
|
||||
foreach (['', '/cache', '/uploads'] as $sub) {
|
||||
$dir = NY_DATA . $sub;
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
}
|
||||
// 保护数据目录(Apache)
|
||||
if (!file_exists(NY_DATA . '/.htaccess')) {
|
||||
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\n");
|
||||
}
|
||||
|
||||
// ---------------- 默认配置 ----------------
|
||||
function ny_default_config(): array
|
||||
{
|
||||
return [
|
||||
'base_url' => 'https://exam.nxxy335.top',
|
||||
'api_prefix' => '/api',
|
||||
'username' => 'admin',
|
||||
'password' => '123456',
|
||||
'verify_ssl' => true,
|
||||
'timeout' => 20,
|
||||
'refresh_interval' => 15,
|
||||
'page_size' => 200,
|
||||
'include_temp_save' => false,
|
||||
'dedupe' => true,
|
||||
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
||||
'full_score' => 100,
|
||||
'full_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
function ny_config_file(): string
|
||||
{
|
||||
return NY_DATA . '/config.json';
|
||||
}
|
||||
|
||||
function ny_load_config(): array
|
||||
{
|
||||
$cfg = ny_default_config();
|
||||
$saved = ny_read_json(ny_config_file(), null);
|
||||
if (is_array($saved)) {
|
||||
foreach ($cfg as $k => $v) {
|
||||
if (array_key_exists($k, $saved)) {
|
||||
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
function ny_save_config(array $cfg): bool
|
||||
{
|
||||
return ny_write_json(ny_config_file(), $cfg);
|
||||
}
|
||||
|
||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||
function ny_str(mixed $v): string
|
||||
{
|
||||
if ($v === null || is_array($v) || is_object($v)) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)$v);
|
||||
}
|
||||
|
||||
// ---------------- JSON 文件读写 ----------------
|
||||
function ny_read_json(string $file, mixed $default = null): mixed
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return $default;
|
||||
}
|
||||
$raw = @file_get_contents($file);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return $default;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return $data === null ? $default : $data;
|
||||
}
|
||||
|
||||
function ny_write_json(string $file, mixed $data): bool
|
||||
{
|
||||
$tmp = $file . '.tmp';
|
||||
$ok = @file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
|
||||
if ($ok === false) {
|
||||
return false;
|
||||
}
|
||||
return @rename($tmp, $file) || @copy($tmp, $file);
|
||||
}
|
||||
|
||||
// ---------------- 输出 ----------------
|
||||
function ny_json(mixed $data, int $http = 200): void
|
||||
{
|
||||
http_response_code($http);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
function ny_ok(mixed $data = null): void
|
||||
{
|
||||
ny_json(['ok' => true, 'data' => $data]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function ny_fail(string $msg, mixed $extra = null): void
|
||||
{
|
||||
ny_json(['ok' => false, 'msg' => $msg, 'extra' => $extra]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------------- 工具函数 ----------------
|
||||
/** 规范化姓名:去空白、去全角空格、统一大小写 */
|
||||
function ny_norm_name(string $s): string
|
||||
{
|
||||
$s = str_replace(["\xC2\xA0", ' '], '', $s);
|
||||
$s = preg_replace('/\s+/u', '', $s) ?? '';
|
||||
return mb_strtolower(trim($s));
|
||||
}
|
||||
|
||||
/** 把任意结构的答卷值压平成字符串 */
|
||||
function ny_flatten_value(mixed $v): string
|
||||
{
|
||||
if ($v === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_bool($v)) {
|
||||
return $v ? '1' : '';
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
return trim((string)$v);
|
||||
}
|
||||
if (is_string($v)) {
|
||||
return trim($v);
|
||||
}
|
||||
if (is_array($v)) {
|
||||
foreach (['value', 'text', 'label', 'name', 'content', 'title'] as $k) {
|
||||
if (isset($v[$k]) && (is_string($v[$k]) || is_numeric($v[$k]))) {
|
||||
return trim((string)$v[$k]);
|
||||
}
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($v as $item) {
|
||||
$s = ny_flatten_value($item);
|
||||
if ($s !== '') {
|
||||
$parts[] = $s;
|
||||
}
|
||||
}
|
||||
return implode(',', $parts);
|
||||
}
|
||||
return trim((string)$v);
|
||||
}
|
||||
|
||||
/** 时间字段 -> 时间戳(秒) */
|
||||
function ny_to_timestamp(mixed $v): int
|
||||
{
|
||||
if ($v === null || $v === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric($v)) {
|
||||
$n = (int)$v;
|
||||
if ($n > 1000000000000) { // 毫秒
|
||||
return intdiv($n, 1000);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
$t = strtotime((string)$v);
|
||||
return $t === false ? 0 : $t;
|
||||
}
|
||||
|
||||
/** 秒 -> 12分34秒 */
|
||||
function ny_format_duration(?int $sec): string
|
||||
{
|
||||
if ($sec === null || $sec < 0) {
|
||||
return '-';
|
||||
}
|
||||
if ($sec < 60) {
|
||||
return $sec . '秒';
|
||||
}
|
||||
$m = intdiv($sec, 60);
|
||||
$s = $sec % 60;
|
||||
if ($m < 60) {
|
||||
return $m . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
|
||||
}
|
||||
$h = intdiv($m, 60);
|
||||
return $h . '小时' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT) . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
|
||||
}
|
||||
|
||||
// ---------------- 类自动加载 ----------------
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$map = [
|
||||
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
|
||||
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
||||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||||
];
|
||||
if (isset($map[$class]) && is_file($map[$class])) {
|
||||
require $map[$class];
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user