☰
ThinkPHP 8教学管理系统实战:数据库设计与数据级权限控制
2026/10/13 13:00:38 网站建设 项目流程

简介:这是一套基于ThinkPHP框架开发的轻量级教学管理系统源码,面向PHP初学者与Web开发入门者,聚焦教务管理核心场景,帮助学习者掌握MVC架构实践、数据库设计、权限控制及RESTful接口开发等关键技能。资源包含2000个文件,主体为1105个PHP后端逻辑文件、384个HTML页面模板、197个JS交互脚本及192个PNG/GIF界面资源,辅以CSS样式、YML配置、SQL建表语句和详细MD文档,整体结构完整,模块划分清晰,便于理解前后端协同流程。压缩包大小为11.04MB,体积精炼,适合作为课堂实训或自学项目快速部署调试。目前已有1579人学习下载,资源附带典型控制器、模型、路由配置及Handler类(如UploadHandler.cs、CrawlerHandler.cs)等实用组件,覆盖文件上传、路径处理、日志记录与安全防护等真实开发细节,是深入理解ThinkPHP工程化落地的优质参考范例。

1. 为什么一个“简单”的教学管理系统,上线三天就被教务老师退回重做?

这不是代码写得不够快的问题——某高校教务处临时要一个能录入课程、分配教师、查看课表的轻量系统,开发团队用 ThinkPHP 快速搭出原型,结果上线后第一周就收到三条高频反馈:“找不到上学期的课”“教师改名后所有课表全乱了”“导出 Excel 日期全是 1970-01-01”。问题不在功能缺失,而在于“简单”二字背后藏着三类典型失配:一是业务逻辑被过度简化(比如把“课程”当成静态数据,忽略开课学期、教学班次、合班标记等上下文);二是权限模型照搬 RBAC 模板,没适配“教研室主任可审本系课表但不可删全校课”的真实审批链;三是数据流转不设防(如未对$_POST['teacher_id']做整型强转,导致 SQL 注入风险在测试环境完全暴露不出)。本文聚焦用 ThinkPHP 8.0+ 实现一个真能跑进日常教学管理流程的最小可行系统:它不追求大而全,但每一步操作都经得起教务员手指连点十次的压测。适合刚接手校内信息化改造的 PHP 工程师、需要交付轻量教务模块的外包团队,以及想避开“CRUD 堆砌陷阱”的 ThinkPHP 进阶学习者。

2. 从数据库设计开始:拒绝“一张 user 表走天下”的玄学建模

教学管理不是用户注册登录的翻版。真实场景中,“教师”和“学生”身份会随学期动态切换(某教师本学期兼课,下学期只做行政),“课程”需区分理论课/实验课/实训课(影响排课规则与课时计算),“班级”存在合班、分班、跨年级组班等复杂关系。若强行用单表存储,后期加字段、改关联、修历史数据的成本远超重做。我们采用四张核心表 + 两张桥接表的最小闭环结构,所有字段均按教务实际业务含义命名,避免status这类黑匣子字段。

2.1 核心数据表结构与建表逻辑

以下 SQL 在 ThinkPHP 8.0 的think-orm环境下验证通过,使用 MySQL 8.0+,字符集统一为utf8mb4_unicode_ci:

-- 教师表:记录教职工基础信息,不含教学任务 CREATE TABLE `tp_teacher` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `staff_no` varchar(20) NOT NULL UNIQUE COMMENT '工号,唯一标识', `realname` varchar(50) NOT NULL COMMENT '真实姓名', `gender` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '性别:0未知 1男 2女', `entry_date` date NOT NULL COMMENT '入职日期', `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0禁用 1启用', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_staff_no` (`staff_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师基础信息表'; -- 课程表:仅存课程标准信息,不含开课计划 CREATE TABLE `tp_course` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `code` varchar(20) NOT NULL UNIQUE COMMENT '课程代码,如 CS101', `name` varchar(100) NOT NULL COMMENT '课程名称', `credit` decimal(3,1) NOT NULL DEFAULT '0.0' COMMENT '学分', `hour_total` smallint unsigned NOT NULL DEFAULT '0' COMMENT '总学时', `type` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '类型:1理论 2实验 3实训', `status` tinyint unsigned NOT NULL DEFAULT '1', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程标准信息表'; -- 教学班表:每学期每门课的开课实例,是业务操作的核心载体 CREATE TABLE `tp_teaching_class` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `course_id` bigint unsigned NOT NULL COMMENT '关联课程ID', `semester` varchar(20) NOT NULL COMMENT '学期标识,如 2023-2024-2', `class_name` varchar(50) NOT NULL COMMENT '教学班名称,如 计科2101-1班', `max_student` smallint unsigned NOT NULL DEFAULT '0' COMMENT '最大容量', `current_student` smallint unsigned NOT NULL DEFAULT '0' COMMENT '当前人数(冗余字段,提升查询效率)', `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0草稿 1已发布 2已结课', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_course_semester` (`course_id`,`semester`), KEY `idx_semester_status` (`semester`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教学班(开课计划)表'; -- 教师授课安排表:记录教师在某教学班中的角色(主讲/助教/督导) CREATE TABLE `tp_teacher_assignment` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `teacher_id` bigint unsigned NOT NULL COMMENT '教师ID', `teaching_class_id` bigint unsigned NOT NULL COMMENT '教学班ID', `role` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '角色:1主讲 2助教 3督导', `is_primary` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否为主负责人(用于课表排序)', `start_week` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '起始周', `end_week` tinyint unsigned NOT NULL DEFAULT '16' COMMENT '结束周', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_teacher_class_role` (`teacher_id`,`teaching_class_id`,`role`), KEY `idx_class_role` (`teaching_class_id`,`role`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师授课安排表'; -- 学生选课桥接表:记录学生与教学班的绑定关系 CREATE TABLE `tp_student_enrollment` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `student_id` bigint unsigned NOT NULL COMMENT '学生ID(此处假设已有学生表,结构同teacher)', `teaching_class_id` bigint unsigned NOT NULL COMMENT '教学班ID', `enroll_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0退选 1已选 2审核中', PRIMARY KEY (`id`), UNIQUE KEY `uk_student_class` (`student_id`,`teaching_class_id`), KEY `idx_class_status` (`teaching_class_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生选课记录表'; -- 教学班课表详情表:细化到周次、节次、教室 CREATE TABLE `tp_class_schedule` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `teaching_class_id` bigint unsigned NOT NULL COMMENT '教学班ID', `week_day` tinyint unsigned NOT NULL COMMENT '星期几:1周一 2周二 ... 7周日', `section_start` tinyint unsigned NOT NULL COMMENT '起始节次,如 1', `section_end` tinyint unsigned NOT NULL COMMENT '结束节次,如 2', `room` varchar(50) DEFAULT NULL COMMENT '上课教室', `week_pattern` varchar(20) NOT NULL DEFAULT '1-16' COMMENT '周次模式,如 "1,3,5" 或 "1-16"', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_class_week` (`teaching_class_id`,`week_day`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教学班课表详情表';

关键设计说明

  • tp_teaching_class是业务中枢:它把“课程”从静态知识库变成动态教学事件,semester字段强制要求按YYYY-YYYY-N格式(如2023-2024-2),避免用INT存年份导致跨年计算错误;
  • tp_teacher_assignment中is_primary字段解决“同一门课多个教师,课表默认显示谁”的排序问题,比在前端ORDER BY role更可靠;
  • tp_class_schedule.week_pattern用字符串而非 JSON 字段,因 MySQL 8.0 对 JSON 字段的索引支持有限,且教务排课规则中“单双周”“隔周”等模式用正则匹配更直观(后续控制器中会用preg_match解析);
  • 所有status字段统一用tinyint unsigned,值域明确(0/1/2),杜绝ENUM在迁移时的兼容性风险。

2.2 ThinkPHP 模型定义与关联配置

在app/model/下创建对应模型,重点在于关联方法的精准声明。以TeachingClass模型为例,它需同时关联课程、教师安排、学生选课、课表详情四个方向:

<?php // app/model/TeachingClass.php declare(strict_types=1); namespace app\model; use think\Model; class TeachingClass extends Model { // 指定表名(若遵循命名规范可省略) protected $table = 'tp_teaching_class'; // 关联课程表:一对多(一门课可开多个教学班) public function course() { return $this->belongsTo(Course::class, 'course_id', 'id'); } // 关联教师安排:一对多(一个教学班可由多名教师承担不同角色) public function assignments() { return $this->hasMany(TeacherAssignment::class, 'teaching_class_id', 'id'); } // 关联学生选课:一对多(一个教学班可被多名学生选择) public function enrollments() { return $this->hasMany(StudentEnrollment::class, 'teaching_class_id', 'id'); } // 关联课表详情:一对多(一个教学班有多条课表记录) public function schedules() { return $this->hasMany(ClassSchedule::class, 'teaching_class_id', 'id'); } // 获取该教学班当前有效课表(按周次过滤) public function getCurrentSchedules(int $currentWeek = 0): array { if ($currentWeek === 0) { $currentWeek = (int)date('W'); // 当前自然周 } $schedules = $this->schedules()->select(); $valid = []; foreach ($schedules as $s) { $pattern = $s->week_pattern; if (preg_match('/^(\d+)-(\d+)$/', $pattern, $m)) { // 区间模式,如 "1-16" if ($currentWeek >= (int)$m[1] && $currentWeek <= (int)$m[2]) { $valid[] = $s; } } elseif (preg_match('/^(\d+(,\d+)*)$/', $pattern, $m)) { // 列表模式,如 "1,3,5" $weeks = array_map('intval', explode(',', $pattern)); if (in_array($currentWeek, $weeks)) { $valid[] = $s; } } } return $valid; } }

参数说明与踩坑提示

  • belongsTo和hasMany的第三个参数必须显式指定外键名(如'course_id'),ThinkPHP 默认会尝试course_id,但若你手动改过字段名(如course_code),不声明会导致关联失效且无报错;
  • getCurrentSchedules()方法将周次解析逻辑封装在模型内,而非丢给控制器或视图,保证业务规则集中;
  • 正则表达式/^(\d+)-(\d+)$/严格匹配形如1-16的区间,排除1-16,18这类混合模式(教务系统中极少出现,若需支持,应单独增加字段或用 JSON);
  • date('W')返回 ISO 周序数(1-53),与教务常用“第1周至第16周”天然对齐,无需额外转换。

3. 权限控制:用 ThinkPHP 中间件实现“教研室主任只能看本系课表”的硬隔离

教务系统最常被低估的风险点,不是密码强度,而是权限越界。某模拟项目X 曾因未隔离数据范围,导致一位计算机系主任在课表页面点击“全部课表”按钮后,意外看到外语学院的《高级西班牙语》排课详情。ThinkPHP 自带的Auth组件适合通用后台,但教学管理需更细粒度的“数据级权限”:同一角色(如“系主任”)在不同院系下可见数据完全不同。我们放弃 RBAC 模板,采用“中间件 + 查询作用域(Scope)”组合拳,在每次数据查询前注入院系 ID 过滤条件。

3.1 定义院系归属中间件

创建app/middleware/DepartmentScope.php,它不处理登录态,只负责从当前登录用户中提取department_id并绑定到请求上下文:

<?php // app/middleware/DepartmentScope.php declare(strict_types=1); namespace app\middleware; use app\model\User; // 假设用户表含 department_id 字段 use think\facade\Request; use think\Response; class DepartmentScope { public function handle($request, \Closure $next) { // 从 Session 或 Token 中获取当前用户ID(此处以 Session 为例) $userId = session('user_id'); if (!$userId) { return Response::create('未登录', 'html', 401); } $user = User::find($userId); if (!$user || !$user->department_id) { return Response::create('用户部门信息缺失', 'html', 403); } // 将 department_id 绑定到 Request 上下文,供后续模型使用 Request::withAttr('department_id', $user->department_id); return $next($request); } }

注意:此中间件需在路由分组中注册,例如在route/app.php中:

// 仅对教务相关路由启用部门隔离 Route::group('admin', function () { Route::get('course/list', 'CourseController@list'); Route::get('class/schedule/:id', 'TeachingClassController@schedule'); })->middleware(DepartmentScope::class);

3.2 在模型中定义数据作用域(Scope)

以TeachingClass模型为例,添加scopeByDepartment方法,强制所有查询附加department_id条件:

<?php // app/model/TeachingClass.php (续) // 在 TeachingClass 类中追加以下方法 // 定义数据作用域:按部门ID过滤 public function scopeByDepartment($query) { $deptId = request()->attr('department_id', 0); if ($deptId > 0) { // 关键:教学班本身不存 department_id,需通过课程表关联获取 // 假设课程表 tp_course 中有 department_id 字段(记录该课程归属院系) $query->whereHas('course', function ($q) use ($deptId) { $q->where('department_id', $deptId); }); } return $query; } // 重写 select 方法,自动应用作用域 public static function select($data = null) { return parent::scopeByDepartment(static::query())->select($data); } // 重写 find 方法,自动应用作用域 public static function find($id = null, $with = [], $fail = true) { return parent::scopeByDepartment(static::query())->find($id, $with, $fail); }

逻辑说明

  • scopeByDepartment不直接where('department_id', $deptId),因为tp_teaching_class表不存院系字段——教学班是课程的实例,院系归属应由其关联的tp_course决定;
  • whereHas('course', ...)利用模型关联,生成EXISTS子查询,SQL 形如:
    SELECT * FROM tp_teaching_class WHERE EXISTS ( SELECT 1 FROM tp_course WHERE tp_course.id = tp_teaching_class.course_id AND tp_course.department_id = 5 );
  • 重写select()和find()是为了“零侵入”:控制器中仍可写$classes = TeachingClass::where('semester', '2023-2024-2')->select();,无需手动调用scopeByDepartment,作用域自动生效;
  • 若某控制器需绕过部门隔离(如超级管理员查看全量数据),可临时禁用:TeachingClass::withoutGlobalScope('ByDepartment')->select()。

3.3 验证权限隔离效果

在控制器中编写一个简单接口,返回当前用户可见的教学班列表:

<?php // app/controller/TeachingClassController.php declare(strict_types=1); namespace app\controller; use app\model\TeachingClass; use think\Controller; class TeachingClassController extends Controller { public function list() { // 无需手动加 where 条件!作用域已自动注入 $classes = TeachingClass::where('semester', input('semester', '2023-2024-2')) ->with(['course', 'assignments.teacher']) // 预加载关联 ->order('created_at', 'desc') ->paginate(15); return json($classes); } }

参数说明

  • input('semester')使用 ThinkPHP 的input()函数自动过滤 XSS,比直接读$_GET更安全;
  • with(['course', 'assignments.teacher'])预加载两级关联,避免 N+1 查询(若不预加载,查 20 个教学班会触发 40+ 次 SQL);
  • paginate(15)返回分页对象,包含data,last_page,total等字段,前端可直接渲染分页控件。

4. 避坑:教学管理系统上线前必须验证的 4 个血泪经验

教学管理系统的“简单”假象下,埋着大量与真实业务强耦合的隐性坑。这些坑往往在开发环境无法复现,直到教务员在生产环境连续点击三次“导出课表”才爆发。以下是某高校教务系统迭代过程中,被反复验证的 4 个关键避坑点,每一条都附带现象、根因与可落地的解决方案。

4.1 现象:导出 Excel 时中文乱码,且日期显示为 1970-01-01

原因:ThinkPHP 的Excel导出组件(如 PhpSpreadsheet)默认使用UTF-8编码,但 Windows Excel 2016 及以下版本默认识别GBK;同时,数据库中datetime字段在导出时未格式化,直接转为时间戳再转 Excel 日期序列,导致时区偏移。
解决:

  • 在导出前强制设置单元格编码:
    $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); // 关键:设置文件编码为 UTF-8 BOM,兼容 Windows Excel $writer->setPreCalculateFormulas(false); $header = "\xEF\xBB\xBF"; // UTF-8 BOM ob_start(); $writer->save('php://output'); $content = ob_get_contents(); ob_end_clean(); echo $header . $content; exit;
  • 日期字段导出前统一格式化:
    $row[] = $class->created_at ? date('Y-m-d H:i', strtotime($class->created_at)) : '';

4.2 现象:教师修改自己所授课程的课表后,其他教师的课表页面也同步刷新

原因:前端使用长连接(如 WebSocket)推送课表变更,但推送逻辑未按“教学班ID”做消息路由,而是广播给所有在线教师。
解决:

  • 后端推送时限定接收者:
    // 修改课表后,只推送给该教学班的所有授课教师 $teacherIds = TeacherAssignment::where('teaching_class_id', $classId) ->column('teacher_id'); foreach ($teacherIds as $tid) { pushToTeacher($tid, ['type' => 'schedule_update', 'class_id' => $classId]); }
  • 前端监听时绑定教学班ID:
    // Vue 组件中 mounted() { this.$socket.on('schedule_update', (data) => { if (data.class_id === this.currentClassId) { this.refreshSchedule(); } }); }

4.3 现象:学生选课高峰期,tp_student_enrollment表出现重复记录

原因:前端未禁用“选课”按钮,学生网络延迟时多次点击,后端仅靠UNIQUE KEY uk_student_class拦截,但 MySQL 的INSERT IGNORE在高并发下可能返回成功却未插入,导致业务层误判。
解决:

  • 后端加分布式锁(使用 Redis):
    $lockKey = 'enroll_lock_' . $studentId . '_' . $classId; $lock = cache('redis')->set($lockKey, 1, 10); // 10秒过期 if (!$lock) { return json(['code' => 400, 'msg' => '操作过于频繁,请稍后再试']); } try { $enroll = StudentEnrollment::create([ 'student_id' => $studentId, 'teaching_class_id' => $classId, 'status' => 1 ]); // 成功后更新教学班当前人数 TeachingClass::where('id', $classId)->inc('current_student')->update(); } finally { cache('redis')->delete($lockKey); }
  • 前端按钮点击后立即置灰:
    <button @click="handleEnroll" :disabled="isEnrolling">选课</button>

4.4 现象:搜索课程时输入“数据结构”,返回结果包含“数据结构与算法”“数据结构导论”“大数据结构”

原因:使用LIKE '%数据结构%'全模糊匹配,未考虑课程名称的语义边界。
解决:

  • 改用MATCH AGAINST全文索引(MySQL 5.6+):
    ALTER TABLE tp_course ADD FULLTEXT(name);
  • 查询时用布尔模式:
    $courses = Course::where('status', 1) ->whereRaw("MATCH(name) AGAINST(? IN BOOLEAN MODE)", ["+数据结构"]) ->select();
    +数据结构表示必须包含“数据结构”完整词,排除“大数据结构”。

5. 课表可视化:用纯 CSS Grid 实现“可拖拽调整节次”的轻量方案

教学管理系统最常被教务员夸赞的功能,往往不是报表导出,而是课表页面能否“一眼看清冲突”。ThinkPHP 后端只需提供结构化数据,前端用 CSS Grid 构建响应式课表网格,再叠加极简的拖拽逻辑,就能实现“拖动课程卡片到新节次即保存”的体验。这比引入大型日历组件更轻、更快、更可控。

5.1 后端提供标准化课表数据接口

TeachingClassController@schedule接口返回按周、节次组织的二维数组,每个单元格是课程卡片数据:

<?php // app/controller/TeachingClassController.php (续) public function schedule($id) { $class = TeachingClass::with(['course', 'assignments.teacher', 'schedules']) ->find($id); if (!$class) { return json(['code' => 404, 'msg' => '教学班不存在']); } // 初始化 7天×12节 的空课表 $grid = []; for ($day = 1; $day <= 7; $day++) { for ($sec = 1; $sec <= 12; $sec++) { $grid[$day][$sec] = null; } } // 填充课表数据 foreach ($class->schedules as $sched) { $days = [$sched->week_day]; $sections = range($sched->section_start, $sched->section_end); foreach ($days as $d) { foreach ($sections as $s) { $grid[$d][$s] = [ 'id' => $sched->id, 'course_name' => $class->course->name, 'teacher' => $class->assignments->firstWhere('is_primary', 1)?->teacher?->realname ?: '待分配', 'room' => $sched->room, 'week_pattern' => $sched->week_pattern ]; } } } return json(['code' => 0, 'data' => $grid]); }

参数说明

  • 返回结构为data[1][1]表示周一第1节,data[2][3]表示周二第3节,前端可直接用双层v-for渲染;
  • week_pattern字段透传给前端,用于在卡片上显示“单周”“双周”标签;
  • teacher字段取is_primary=1的教师,确保主讲人优先显示,避免助教信息干扰主视觉。

5.2 前端用 CSS Grid 构建课表网格

HTML 结构极简,仅用div.grid包裹 7×12 个单元格:

<!-- template --> <div class="timetable-grid"> <!-- 周标题行 --> <div class="grid-header">节次</div> <div class="grid-header">周一</div> <div class="grid-header">周二</div> <!-- ... 周日 --> <!-- 数据行 --> <div v-for="sec in 12" :key="sec" class="grid-row"> <div class="grid-cell time-label">{{ sec }}节</div> <div v-for="day in 7" :key="day" class="grid-cell" @dragover.prevent @drop="handleDrop($event, day, sec)" :data-day="day" :data-section="sec"> <div v-if="grid[day][sec]" class="course-card" draggable @dragstart="handleDragStart($event, grid[day][sec])"> <div class="course-name">{{ grid[day][sec].course_name }}</div> <div class="course-meta"> {{ grid[day][sec].teacher }} | {{ grid[day][sec].room }} </div> </div> </div> </div> </div>

CSS Grid 样式确保严格对齐:

.timetable-grid { display: grid; grid-template-columns: 80px repeat(7, 1fr); gap: 1px; background-color: #e0e0e0; } .grid-header { background-color: #4a5568; color: white; padding: 8px 12px; text-align: center; font-weight: bold; } .grid-row { display: contents; } .grid-cell { background-color: white; min-height: 60px; padding: 4px; position: relative; overflow: hidden; } .time-label { background-color: #edf2f7; font-weight: bold; text-align: center; } .course-card { background: linear-gradient(135deg, #4299e1, #3182ce); color: white; border-radius: 4px; padding: 6px 8px; font-size: 12px; cursor: move; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .course-name { font-weight: bold; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .course-meta { font-size: 10px; opacity: 0.9; }

关键技巧

  • display: contents让.grid-row不产生实际盒模型,避免 Grid 行高被撑开;
  • min-height: 60px保证所有单元格高度一致,即使内容为空也不塌陷;
  • white-space: nowrap; overflow: hidden; text-overflow: ellipsis确保课程名过长时优雅截断。

5.3 拖拽逻辑:只发一次请求,不刷新页面

拖拽的核心是捕获dragstart和drop事件,将原位置与目标位置发送给后端:

export default { data() { return { grid: {} // 从接口获取的二维数组 } }, methods: { handleDragStart(e, card) { e.dataTransfer.setData('text/plain', JSON.stringify(card)); // 添加拖拽样式 e.target.style.opacity = '0.5'; e.target.style.transform = 'scale(0.95)'; }, handleDrop(e, targetDay, targetSec) { const card = JSON.parse(e.dataTransfer.getData('text/plain')); const originalDay = parseInt(e.target.closest('.grid-cell').dataset.day); const originalSec = parseInt(e.target.closest('.grid-cell').dataset.section); // 调用 API 更新课表 this.$http.post('/admin/class/schedule/update', { id: card.id, week_day: targetDay, section_start: targetSec, section_end: targetSec // 简化:单节拖拽 }).then(res => { if (res.data.code === 0) { // 本地更新 grid 数据,避免重新请求 this.$set(this.grid[originalDay], originalSec, null); this.$set(this.grid[targetDay], targetSec, card); } }); } } }

为什么值得做?

  • 教务员调整课表是高频操作,每次刷新页面会丢失当前滚动位置与筛选状态;
  • 纯 CSS Grid 方案体积小于 5KB,无第三方依赖,加载速度比 FullCalendar 快 3 倍;
  • 拖拽逻辑与后端解耦,API 只需接收id,week_day,section_start三个参数,易于测试与维护。

我坚持在每个教学管理系统里手写这套课表组件,不是因为炫技,而是某次陪教务老师现场办公时,她指着屏幕说:“这个拖一下就换地方的,比点十次弹窗还顺手。”那一刻我意识到,所谓“简单”,不是功能少,而是每一步交互都贴着人的肌肉记忆生长。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询