Files
ny-exam-platform/bootstrap.php
T
nxxy335 ba0e64ed51 feat: 导出按班级/组别筛选 + 随机抽取页 + 零依赖 XLSX 导出
导出跟随分类筛选
- api.php export / export_absent 新增 cls、grp 参数,按当前筛选过滤 rows
- 缺考口径与页面完全一致:筛选后名单 − 筛选后已提交(按学号匹配)
- 导出文件名附带班级 / 组别(如 成绩明细_高一数学月考_高一(1)班_A组_时间戳.csv)
- app.js 两个导出按钮追加 cls/grp 参数并提示导出范围

随机抽取页 draw.php
- 来源:学号名单 / 某试卷已提交人员;支持班级、组别筛选与抽取人数设置
- 「排除已抽中」累计去重,抽取结果存入 localStorage,刷新后仍可下载
- 抽取动画(滚动定格),结果表格含序号 / 学号 / 姓名 / 班级 / 组别
- 下载「白名单导入模板.xlsx」「白名单列表.xlsx」:两列 姓名 / 登陆账号

XLSX 导出能力
- 新增 lib/XlsxWriter.php:零依赖生成 xlsx,有 zip 扩展走 ZipArchive,
  无则回退手写 ZIP(stored);单元格按共享字符串 + 文本格式(numFmtId=49)
  写入,长学号不会变科学计数法
- 新增 api.php action=draw_export,输出与「白名单列表.xlsx」模板一致的结构

其他
- index.php 顶栏新增「随机抽取」入口;支持 index.php#roster 直接打开名单管理
- tools/test-xlsxwriter.php 自测:18 项全通过(含无 ZipArchive 的手写打包路径)
2026-09-09 11:11:14 +08:00

346 lines
9.8 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
// ---------------- 会话 ----------------
function ny_session_start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name('NYSESS');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $secure,
]);
@session_start();
}
/** 当前是否已登录(本系统会话) */
function ny_is_auth(): bool
{
ny_session_start();
return !empty($_SESSION['ny_auth']);
}
/** 标记登录 */
function ny_login(): void
{
ny_session_start();
$_SESSION['ny_auth'] = true;
}
/** 登出 */
function ny_logout(): void
{
ny_session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 判断是否已安装(有过连接配置且能流转):文件 config 或 DB settings 存在 base_url 且账号非空 */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
// 有无可用的后端(文件 config 存在 ,或 DB 连得上)
if (class_exists('Db') && Db::isConnected()) {
return true;
}
return is_file(ny_config_file());
}
// ---------------- 目录初始化 ----------------
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' => [],
'layout' => 'default',
];
}
function ny_config_file(): string
{
return NY_DATA . '/config.json';
}
function ny_load_config(): array
{
// 数据库已初始化且能连上 → 从 DB 读,否则回退文件
$dbCfg = ny_db_config();
if ($dbCfg !== null) {
$cfg = ny_default_config();
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $dbCfg)) {
$cfg[$k] = is_bool($v) ? (bool)$dbCfg[$k] : $dbCfg[$k];
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
return $cfg;
}
$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
{
// 数据库已连接则写入 DB settings(键 config = JSON),否则回退文件
if (class_exists('Db')) {
$db = Db::pdo();
if ($db !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
return true;
} catch (Throwable $e) {
// 落库失败则回退文件
}
}
}
return ny_write_json(ny_config_file(), $cfg);
}
/** 数据库初始化后从 DB 读取配置(无则返回 null) */
function ny_db_config(): ?array
{
if (!class_exists('Db')) {
return null;
}
$db = Db::pdo();
if ($db === null) {
return null;
}
try {
$raw = (string)(Db::settingsAll()['config'] ?? '');
} catch (Throwable $e) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
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',
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php',
'Db' => NY_ROOT . '/lib/Db.php',
];
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
}
});