简介:这是一套基于PHP与Excel文件构建的通用课表查询系统源码,面向Web开发初学者、计算机专业毕业设计学生及需要课程管理功能的学校或培训机构。系统以PHP处理请求与数据库交互,借助Excel文件实现课程数据的导入导出,并配有HTML、CSS、JavaScript搭建的前端界面,涵盖后端编程、数据库操作、文件处理与页面设计等完整环节。压缩包共19个文件,包含8个xls表格、5个php脚本、2个html页面及css、js、htm等,整体约68KB,其中xls用于存放课程表与财务报表等示例数据,php负责核心逻辑与数据库连接,前端文件承担界面展示与交互。资源已积累242人学习下载,读者可从中获取入口文件与公共函数模块的目录组织方式、Excel读写与数据库存储的实现思路,以及一份可参考的毕业设计项目结构,适合作为课程设计、案例教学与PHP实战练习的参考素材。
1. 一个 zip 包丢过来:php+excel 通用课表查询系统到底在解决什么
教务老师把一份 Excel 课表发到群里,学生问「周三下午第三节在哪间教室」,老师要翻半天表格;换一个班级、换一个学期,又得重新发一份文件。这种场景下,php+excel 通用课表查询系统的价值就出来了:把 Excel 当数据源,用 PHP 做一个网页查询入口,学生输入班级、周次、星期几,直接返回课程名、教师、教室。它不依赖数据库,不需要复杂部署,一个 zip 包解压到支持 PHP 的虚拟主机或本地环境就能跑。
这套方案适合三类人:一是学校信息老师,手头只有 Excel 课表,不想折腾 MySQL;二是 PHP 初学者,想找一个「读取本地文件 + 输出网页」的完整小项目练手;三是需要快速交付内部查询工具的后端开发,用 Excel 做配置表,PHP 做接口层。核心逻辑就三步:PHP 读取 Excel 文件、按查询条件过滤行、把结果渲染成 HTML 表格或 JSON。难点不在代码量,而在 Excel 格式兼容、中文编码、查询性能边界和部署时的路径问题。下面按「先跑通最小查询,再处理格式和性能,最后避坑」的顺序拆开讲。
2. 把 Excel 当数据库用:PHP 读取课表文件的三种落地方式
2.1 为什么多数课表查询系统最终都绕不开 PhpSpreadsheet
PHP 读取 Excel 的扩展经历过几个阶段。早期用 PHPExcel,后来官方停止维护,社区迁到 PhpSpreadsheet。现在常见做法是 Composer 安装phpoffice/phpspreadsheet,它支持 xls、xlsx、ods、csv,能读单元格格式、合并单元格、公式计算结果。对于课表这种「一行代表一条课程记录」的二维表,用 PhpSpreadsheet 的toArray()方法一次性转成数组,再按列名映射字段,是最稳的路径。
选型理由很直接:课表文件通常由教务系统导出,格式不统一,有的用合并单元格表示「上午」「下午」,有的把星期几写成「周一」「星期一」「Mon」。PhpSpreadsheet 能拿到合并单元格的起始行和跨度,也能读取单元格的原始值,方便做归一化。如果只用fgetcsv读 CSV,遇到 xlsx 就得先让用户另存为 CSV,体验差一截。另一个常见做法是用spout库,它内存占用低,适合几万行的大课表,但课表一般几百行,PhpSpreadsheet 足够。
安装命令如下,注意 PHP 版本要求 7.4 以上,PHP 8.x 兼容性已经稳定:
composer require phpoffice/phpspreadsheet如果虚拟主机没有 Composer,也可以下载 PhpSpreadsheet 的 release 包,手动引入autoload.php。但手动引入容易漏依赖,比如zip扩展、xml扩展、gd扩展。常见报错Class 'ZipArchive' not found就是 PHP 没开 zip 扩展,需要在php.ini里去掉;extension=zip前面的分号。
2.2 最小查询脚本:从 Excel 到 JSON 接口的完整代码
下面这段代码假设课表文件schedule.xlsx放在同目录,第一行是表头:班级、星期、节次、课程、教师、教室。查询参数通过 GET 传入class、day、section。
<?php // query.php require 'vendor/autoload.php'; use PhpOffice\PhpSpreadsheet\IOFactory; header('Content-Type: application/json; charset=utf-8'); // 1. 接收查询参数,做基础过滤 $class = isset($_GET['class']) ? trim($_GET['class']) : ''; $day = isset($_GET['day']) ? trim($_GET['day']) : ''; $section = isset($_GET['section']) ? trim($_GET['section']) : ''; if ($class === '') { echo json_encode(['code' => 400, 'msg' => '缺少班级参数'], JSON_UNESCAPED_UNICODE); exit; } // 2. 加载 Excel 文件 $file = __DIR__ . '/schedule.xlsx'; if (!file_exists($file)) { echo json_encode(['code' => 404, 'msg' => '课表文件不存在'], JSON_UNESCAPED_UNICODE); exit; } $spreadsheet = IOFactory::load($file); $sheetData = $spreadsheet->getActiveSheet()->toArray(null, true, true, true); // 3. 第一行作为表头,建立列名到列字母的映射 $header = []; foreach ($sheetData[1] as $col => $name) { $header[trim($name)] = $col; } // 4. 逐行过滤,匹配查询条件 $result = []; foreach ($sheetData as $rowIndex => $row) { if ($rowIndex == 1) continue; // 跳过表头 $rowClass = isset($row[$header['班级']]) ? trim($row[$header['班级']]) : ''; $rowDay = isset($row[$header['星期']]) ? trim($row[$header['星期']]) : ''; $rowSection = isset($row[$header['节次']]) ? trim($row[$header['节次']]) : ''; if ($rowClass !== $class) continue; if ($day !== '' && $rowDay !== $day) continue; if ($section !== '' && $rowSection !== $section) continue; $result[] = [ '星期' => $rowDay, '节次' => $rowSection, '课程' => isset($row[$header['课程']]) ? trim($row[$header['课程']]) : '', '教师' => isset($row[$header['教师']]) ? trim($row[$header['教师']]) : '', '教室' => isset($row[$header['教室']]) ? trim($row[$header['教室']]) : '', ]; } // 5. 输出 JSON echo json_encode([ 'code' => 0, 'msg' => 'ok', 'data' => $result, ], JSON_UNESCAPED_UNICODE);逻辑说明:IOFactory::load()会自动识别文件格式,xlsx 和 xls 都能处理。toArray(null, true, true, true)四个参数分别表示:返回 null 而不是空字符串、计算公式、格式化日期、使用列字母作为键。第四点很关键,用列字母做键后,表头映射才能对上。过滤时先按班级精确匹配,星期和节次作为可选条件,这样前端可以只传班级查全部,也可以传班级加星期查某一天。
参数说明:class必填,对应 Excel 里的班级列;day和section选填,值要和 Excel 里保持一致,比如 Excel 写「周一」,传「星期一」就查不到。如果课表里星期几写法不统一,需要在读取后做一次归一化,比如把「周一」「星期一」「Mon」都转成「1」。
2.3 前端查询页:一个 HTML 表单加 fetch 请求就够
后端接口有了,前端不需要框架。一个index.html放在同目录,用fetch调query.php,把返回的 JSON 渲染成表格。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>课表查询</title> </head> <body> <h2>课表查询</h2> <label>班级:<input id="class" type="text" placeholder="如:计科2101"></label> <label>星期:<input id="day" type="text" placeholder="如:周一"></label> <label>节次:<input id="section" type="text" placeholder="如:3-4节"></label> <button onclick="query()">查询</button> <table border="1" id="result" style="margin-top:16px;"> <thead> <tr><th>星期</th><th>节次</th><th>课程</th><th>教师</th><th>教室</th></tr> </thead> <tbody></tbody> </table> <script> async function query() { const cls = document.getElementById('class').value.trim(); const day = document.getElementById('day').value.trim(); const sec = document.getElementById('section').value.trim(); if (!cls) { alert('请输入班级'); return; } const url = `query.php?class=${encodeURIComponent(cls)}&day=${encodeURIComponent(day)}§ion=${encodeURIComponent(sec)}`; const res = await fetch(url); const json = await res.json(); const tbody = document.querySelector('#result tbody'); tbody.innerHTML = ''; if (json.code !== 0) { alert(json.msg); return; } json.data.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = `<td>${item.星期}</td><td>${item.节次}</td><td>${item.课程}</td><td>${item.教师}</td><td>${item.教室}</td>`; tbody.appendChild(tr); }); } </script> </body> </html>这段前端代码没有依赖,复制就能用。encodeURIComponent处理中文参数,避免 URL 里出现乱码。如果查询结果为空,表格保持空行,不会报错。常见做法是在tbody里加一行「暂无数据」,但这不是必须的。
提示:如果前端页面和 PHP 接口不在同一个域名下,会遇到跨域问题。常见做法是在
query.php顶部加header('Access-Control-Allow-Origin: *');,但生产环境建议指定具体域名,不要长期用星号。
3. 课表格式千奇百怪:合并单元格、中文编码和日期字段的处理
3.1 合并单元格拆解:把「上午」跨行还原成每一节的星期
教务系统导出的课表经常长这样:第一列是「上午」,下面合并了三行,分别对应第 1、2、3 节。用toArray()读出来,合并区域的非首行会是 null。如果不处理,查询「周三第 2 节」时,星期列可能是空的。
处理思路是:先读取合并单元格信息,建立一个「行号 → 合并值」的映射,再在遍历时填充。PhpSpreadsheet 提供getMergeCells()方法,返回类似A2:A4的区间字符串。
$sheet = $spreadsheet->getActiveSheet(); $mergeCells = $sheet->getMergeCells(); $fillMap = []; // 行号 => 值 foreach ($mergeCells as $range) { // 解析 A2:A4 这种区间 list($start, $end) = explode(':', $range); $startRow = (int) preg_replace('/[A-Z]/', '', $start); $endRow = (int) preg_replace('/[A-Z]/', '', $end); $colLetter = preg_replace('/[0-9]/', '', $start); $value = $sheet->getCell($start)->getValue(); for ($r = $startRow; $r <= $endRow; $r++) { $fillMap[$r][$colLetter] = $value; } }然后在遍历$sheetData时,如果某个单元格为空,就从$fillMap里取对应值。注意toArray()返回的键是列字母,所以填充时也要按列字母匹配。这一步做完,合并单元格的课表就能像普通表格一样查询。
参数说明:getMergeCells()返回的是当前工作表所有合并区域,如果课表有多个 sheet,需要对每个 sheet 分别处理。常见坑是合并区域跨列,比如「上午」合并了 A 列和 B 列,解析时只取起始列字母,填充时也只填起始列,其他列仍然为空。更稳的做法是解析区间时同时记录列范围,但课表场景下跨列合并较少,按起始列处理通常够用。
3.2 中文编码与 BOM:为什么查询结果返回乱码
Excel 文件本身是二进制格式,PhpSpreadsheet 读出来是 UTF-8 字符串,一般不会乱码。乱码通常出在两个地方:一是 CSV 文件带 BOM 头,PHP 读第一列时会把 BOM 当成内容的一部分;二是 PHP 文件本身保存成了 GBK 编码,而输出声明是 UTF-8。
CSV 的 BOM 处理很简单,读取后去掉前三个字节:
$content = file_get_contents('schedule.csv'); $content = str_replace("\xEF\xBB\xBF", '', $content);如果是 xlsx,不需要这一步。但要注意 PHP 文件保存时用 UTF-8 无 BOM,否则header('Content-Type: application/json; charset=utf-8')之前如果有输出,会导致 header 失效。常见报错Cannot modify header information - headers already sent就是文件里有 BOM 或空行。
另一个编码坑是班级名里的特殊字符,比如「计科2101(中外合作)」,括号是全角还是半角,前端传参和后端匹配必须一致。常见做法是在查询前统一做一次str_replace,把全角括号转半角,或者用mb_convert_kana处理日文假名,但中文场景下手动替换更可控。
3.3 节次字段的归一化:把「3-4节」拆成可比较的值
课表里的节次写法很多:「3-4节」「第3-4节」「3、4节」「3,4」。如果用户输入「3-4节」能查到,输入「3」就查不到,体验不好。归一化思路是提取数字,存成起始节和结束节两个字段。
function parseSection($raw) { // 提取所有数字 preg_match_all('/\d+/', $raw, $matches); $nums = $matches[0]; if (count($nums) >= 2) { return [(int)$nums[0], (int)$nums[1]]; } if (count($nums) == 1) { return [(int)$nums[0], (int)$nums[0]]; } return [0, 0]; }查询时,如果用户传了节次,也做同样解析,然后判断用户节次是否落在课程的节次区间内。这样「3」能匹配「3-4节」,「4」也能匹配。参数说明:preg_match_all提取所有连续数字,3-4会得到[3,4],3、4也是[3,4],第3节得到[3]。如果课表里有「单周3-4节」,数字提取仍然有效,但单双周逻辑需要额外字段,不在基础查询范围内。
注意:归一化后的节次只用于查询匹配,展示时仍然用原始字符串,避免用户看到「3-4」不知道是第几节。
4. 查询性能与部署:几百行课表怎么查都不该卡
4.1 每次请求都读 Excel 的代价与缓存方案
上面的最小脚本每次查询都IOFactory::load()一次 Excel。几百行的 xlsx 文件,加载一次大约 50 到 200 毫秒,并发上来后 CPU 会吃紧。常见优化是加一层缓存:第一次读取后把数组序列化存到文件或 APCu,后续请求直接读缓存。
$cacheFile = __DIR__ . '/cache/schedule.php'; $cacheTtl = 3600; // 1小时 if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $cacheTtl)) { $sheetData = include $cacheFile; } else { $spreadsheet = IOFactory::load($file); $sheetData = $spreadsheet->getActiveSheet()->toArray(null, true, true, true); // 写入缓存,用 var_export 生成可 include 的 PHP 文件 $cacheContent = "<?php\nreturn " . var_export($sheetData, true) . ";\n"; file_put_contents($cacheFile, $cacheContent, LOCK_EX); }逻辑说明:缓存文件用var_export生成,比serialize可读性好,也避免反序列化漏洞。filemtime判断过期,LOCK_EX防止并发写入冲突。参数说明:$cacheTtl根据课表更新频率设置,一天更新一次就设 86400,频繁更新就设 300。如果服务器有 APCu,可以用apcu_store和apcu_fetch,速度更快,但虚拟主机不一定支持。
4.2 部署到虚拟主机:路径、权限和 PHP 版本检查
zip 包解压后,目录结构通常是query.php、index.html、vendor/、schedule.xlsx。上传到虚拟主机的public_html或wwwroot后,需要确认三件事:
第一,PHP 版本。PhpSpreadsheet 要求 PHP 7.4 以上,PHP 8.0 到 8.3 都兼容。在query.php里加phpinfo()可以看版本,但生产环境不要留。常见做法是写一个check.php,输出PHP_VERSION和已加载扩展,确认zip、xml、gd、mbstring都在。
第二,目录权限。缓存目录cache/需要可写,Linux 下chmod 755或775,Windows 下给IIS_IUSRS写权限。如果缓存写入失败,file_put_contents返回 false,查询会退化成每次读 Excel,不会报错但变慢。
第三,文件路径。__DIR__在虚拟主机上通常没问题,但如果用了符号链接或子目录部署,schedule.xlsx的相对路径可能不对。常见做法是把课表文件路径写成配置项,放在config.php里,方便修改。
// config.php return [ 'excel_path' => __DIR__ . '/data/schedule.xlsx', 'cache_path' => __DIR__ . '/cache/schedule.php', 'cache_ttl' => 3600, ];然后在query.php里$config = require 'config.php';,用$config['excel_path']替代硬编码路径。这样换课表文件不用改代码。
4.3 用 SQLite 做中间层:什么时候该放弃直接读 Excel
如果课表数据超过几千行,或者需要支持模糊查询、多条件组合、分页,直接读 Excel 会越来越吃力。常见做法是把 Excel 导入 SQLite,用 SQL 查询。SQLite 是单文件数据库,不需要额外服务,PHP 默认支持。
导入脚本:
$db = new PDO('sqlite:' . __DIR__ . '/schedule.db'); $db->exec('CREATE TABLE IF NOT EXISTS schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, class TEXT, day TEXT, section TEXT, course TEXT, teacher TEXT, room TEXT )'); $db->exec('DELETE FROM schedule'); // 清空旧数据 $stmt = $db->prepare('INSERT INTO schedule (class, day, section, course, teacher, room) VALUES (?,?,?,?,?,?)'); foreach ($sheetData as $rowIndex => $row) { if ($rowIndex == 1) continue; $stmt->execute([ trim($row[$header['班级']]), trim($row[$header['星期']]), trim($row[$header['节次']]), trim($row[$header['课程']]), trim($row[$header['教师']]), trim($row[$header['教室']]), ]); }查询时用SELECT * FROM schedule WHERE class = ? AND day = ?,性能比遍历数组好一个量级。参数说明:SQLite 文件放在__DIR__下,注意不要放在 Web 可访问目录,或者用.htaccess禁止访问.db文件。导入脚本可以做成定时任务,每天凌晨跑一次,保持和 Excel 同步。
提示:SQLite 的并发写入能力弱,但课表查询是读多写少,完全够用。如果多个班级同时查询,读操作不会互相阻塞。
5. 避坑与排查:课表查询系统上线后最容易翻车的 5 个点
5.1 现象:查询返回空数组,但 Excel 里明明有数据
原因通常是表头映射失败。toArray()返回的第一行如果包含合并单元格或空列,$header数组里可能没有「班级」这个键,导致$header['班级']报 undefined index,后续匹配全部跳过。解决方法是先打印$header看键名,确认 Excel 第一行没有多余空格或换行。常见做法是用trim()处理表头,并且加一层判断:如果!isset($header['班级']),直接返回错误提示「课表格式不正确,缺少班级列」。
5.2 现象:中文班级名查询不到,英文和数字正常
原因是 URL 编码或文件编码不一致。前端用encodeURIComponent编码后,PHP 的$_GET会自动解码,一般没问题。但如果 PHP 文件保存为 GBK,而 Excel 内容是 UTF-8,字符串比较会失败。解决方法是统一用 UTF-8:PHP 文件无 BOM 保存,header声明 UTF-8,Excel 另存为 xlsx 而不是 CSV。如果必须用 CSV,读取后做mb_convert_encoding($value, 'UTF-8', 'GBK')。
5.3 现象:PhpSpreadsheet 报错「ZipArchive not found」
原因是 PHP 没有启用 zip 扩展。xlsx 本质是 zip 压缩包,没有 zip 扩展就无法解压。解决方法是在php.ini里找到;extension=zip,去掉分号,重启 PHP 或 Web 服务。虚拟主机如果没有权限改php.ini,可以在控制面板里切换 PHP 版本,或者联系空间商开启。另一个类似报错是XMLReader not found,需要开xml扩展。
5.4 现象:缓存文件不更新,课表改了查询结果还是旧的
原因是缓存过期时间没到,或者缓存文件权限不对导致写入失败但旧缓存还在。解决方法是先检查cache/目录权限,再确认filemtime返回的时间。调试时可以临时把$cacheTtl设为 0,强制每次重新生成。常见做法是在后台加一个「刷新缓存」按钮,删除缓存文件,或者用touch更新 Excel 文件的修改时间触发缓存失效。
5.5 现象:并发查询时页面卡死或返回 500
原因是每次请求都加载 PhpSpreadsheet,内存占用高,并发上来后 PHP 进程耗尽。解决方法是启用缓存,把 Excel 转成数组或 SQLite,避免重复解析。如果虚拟主机限制了memory_limit,可以在query.php顶部加ini_set('memory_limit', '256M');,但更根本的是减少单次请求的内存开销。另一个原因是缓存文件写入时没有加锁,多个请求同时写导致文件损坏,用LOCK_EX可以避免。
6. 从能查到好用:给课表查询加一个「周次」维度和导出功能
基础查询跑通后,最值得加的功能是周次。大学课表不是每周都一样,有的课单周上,有的双周上,有的只上前 8 周。Excel 里通常有一列「周次」,写法可能是「1-8」「1,3,5」「单周」。处理方式和节次类似,提取数字区间,查询时判断当前周是否在区间内。
function parseWeeks($raw) { $raw = trim($raw); if ($raw === '单周') return ['type' => 'odd']; if ($raw === '双周') return ['type' => 'even']; preg_match_all('/\d+/', $raw, $m); $nums = array_map('intval', $m[0]); if (count($nums) >= 2) return ['type' => 'range', 'start' => $nums[0], 'end' => $nums[1]]; if (count($nums) == 1) return ['type' => 'range', 'start' => $nums[0], 'end' => $nums[0]]; return ['type' => 'all']; } function matchWeek($parsed, $currentWeek) { switch ($parsed['type']) { case 'all': return true; case 'odd': return $currentWeek % 2 === 1; case 'even': return $currentWeek % 2 === 0; case 'range': return $currentWeek >= $parsed['start'] && $currentWeek <= $parsed['end']; } return false; }查询接口加一个week参数,前端加一个周次输入框。这样学生查「第 5 周周三第 3 节」就能精确匹配。参数说明:parseWeeks把「1-8」解析成范围,「单周」「双周」解析成类型,其他情况默认全部周次。matchWeek根据当前周判断是否匹配。注意周次从 1 开始,不是 0。
另一个实用功能是导出查询结果为 Excel。PhpSpreadsheet 既能读也能写,把$result数组写成 xlsx 下载:
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->fromArray(['星期', '节次', '课程', '教师', '教室'], null, 'A1'); $sheet->fromArray($result, null, 'A2'); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment; filename="query_result.xlsx"'); $writer = new Xlsx($spreadsheet); $writer->save('php://output'); exit;这段代码放在export.php里,前端加一个「导出」按钮指向它。注意fromArray的第二个参数是 null 表示不设置额外参数,第三个参数是起始单元格。导出前要确保没有其他输出,否则文件会损坏。
我自己的习惯是:每次改完课表 Excel,先手动访问一次query.php?class=测试班级,确认返回正常,再让教务老师更新文件。缓存时间设短一点,比如 300 秒,避免改了课表学生查不到。这套系统不复杂,但细节多,尤其是合并单元格和中文编码,翻车一次就要查半天。希望帮到你。
本文还有配套的精品资源,点击获取