教培管理系统
用 TypeScript 全栈打造教务管理系统从业务建模到 SQLite WASM 内存模式的工程实践项目地址暂时不公开技术栈TypeScript 5.6 React 18 Express 4 SQLite (sql.js WASM)代码规模前端 5646 行 / 后端 8932 行 / 24 张数据库表 / 80 API 端点引言为什么教培机构需要一个重型教务系统中小型教育培训机构的教务管理表面上看不过是记个学生信息、排个课但一旦深入业务场景就会发现——它是一个高度联动的状态机。一个学生从潜在生源到毕业离校至少经历招生登记 → 分班 → 报课 → 缴费 → 排课 → 打卡上课 → 请假退课时 → 课时不足预警 → 续费/退费 → 教师工资核算。任何一个环节的数据变动都会像涟漪一样扩散到其他模块。市面上的 SaaS 产品当然能解决这些问题但对于一些有定制化需求、对数据隐私敏感的机构来说一套可自主部署、全链路可控的开源方案往往更具吸引力。尖角教务管理系统就是为解决这个问题而生的。本文将从架构设计、业务建模、数据库方案三个维度深入拆解这个全栈 TypeScript 项目的工程实践。一、技术选型为什么是全栈 TypeScript1.1 统一语言的工程红利决策维度选型理由语言TypeScript 5.6前后端类型共享types/index.ts一套接口定义贯穿双端前端React 18 Ant Design 5 Vite 5企业级组件库 极速 HMR 开发体验后端Express 4轻量、成熟、中间件生态丰富数据库SQLite (sql.js WASM)零依赖部署内存模式读零延迟图表ECharts 5报表中心的可视化需求Excelxlsx (SheetJS)批量导入导出的刚需全栈 TypeScript 的最大好处不是少学一门语言而是类型契约的前后同步。当你在types/index.ts中定义了一个Student接口interfaceStudent{id:number;name:string;gender:string;age:number;grade:string;stage:string;// 小学 | 初中 | 高中enrollment_date:string;parent_name:string;phone:string;wechat:string;remark:string;}前端调 API 时能拿到完整的类型推导后端res.json(student)时也遵循同一份契约。这种编译时联调的能力在 21 个路由模块、80 个 API 端点的规模下价值被显著放大。1.2 项目目录结构edu_tra_project/ ├── frontend/ # React 前端 │ ├── vite.config.ts # Vite 配置/api 反代 allowedHosts │ └── src/ │ ├── main.tsx # 应用入口 │ ├── App.tsx # 根组件路由 全局 Provider │ ├── api/index.ts # Axios 实例拦截器解包 res.data │ ├── types/index.ts # 全局 TS 接口定义 │ ├── context/ # AuthContext / NotificationContext │ ├── components/ # AppLayout / DraggableModal │ └── pages/ # 13 个业务页面组件 ├── backend/ # Express 后端 │ ├── data.db # SQLite 持久化文件 │ └── src/ │ ├── index.ts # 入口port 3001 CORS JSON body │ ├── db/ │ │ ├── database.ts # 24 张表 Schema Migration │ │ ├── compat.ts # DatabaseCompat 封装层 │ │ └── seed.ts # 种子数据自动注入 │ └── routes/ # 21 个路由模块 ├── start_all.bat # Windows 一键启动 ├── start_backend.vbs # VBS 静默启动后端 ├── start_frontend.vbs # VBS 静默启动前端 └── stop_all.vbs # 一键停止所有服务二、业务建模教培行业的领域驱动设计教培机构的业务逻辑看似简单实则暗藏大量状态联动。下面拆解几个核心业务模型的建模思路。2.1 年级 → 学段 → 定价的三级映射教培机构的定价通常不是按课程而是按学段来的。一个五年级学生报数学课价格取决于小学这个学段而不是五年级本身。系统中通过pricing_standards表实现这一映射CREATETABLEpricing_standards(idINTEGERPRIMARYKEYAUTOINCREMENT,gradeTEXTNOTNULL,-- 年级一年级、初二、高三...unit_priceREALNOTNULL,-- 课次单价元created_atDATETIMEDEFAULTCURRENT_TIMESTAMP);当学生报课时系统自动根据年级查询单价乘以总课次得出应收金额// 报课联动应收金额 定价单价 × 课次数constpricingdb.query(SELECT unit_price FROM pricing_standards WHERE grade ?,[studentGrade]);constreceivableAmountpricing[0].unit_price*totalSessions;这种设计让调价只改一处所有关联的应收金额自动同步。2.2 课时的蓄水池模型课时是教培系统最核心的资产。可以把每个学生每门课的课时理解为一个蓄水池报课进水→ student_course_hours 表 │ 打卡上课出水←──┤ │ 请假退还回流←──┤ │ 课程到期冻结←──┘核心表结构-- 课时额度蓄水池水位CREATETABLEstudent_course_hours(idINTEGERPRIMARYKEYAUTOINCREMENT,student_idINTEGERNOTNULL,class_idINTEGERNOTNULL,total_hoursINTEGERDEFAULT0,-- 总课时consumed_hoursINTEGERDEFAULT0,-- 已消耗-- 剩余 total_hours - consumed_hours extensions);-- 课时变动流水进出明细CREATETABLEclass_hour_consumption(idINTEGERPRIMARYKEYAUTOINCREMENT,student_idINTEGER,class_idINTEGER,hours_consumedINTEGERDEFAULT2,-- 单次消耗课时teacher_idINTEGER,consumption_dateDATETIME);-- 课时调整请假退还等CREATETABLEclass_hour_extensions(idINTEGERPRIMARYKEYAUTOINCREMENT,student_idINTEGER,class_idINTEGER,hours_adjustedINTEGER,-- 正数退还负数扣减reasonTEXT,adjusted_atDATETIME);剩余课时的计算公式为total_hours - consumed_hours Σ(extensions.hours_adjusted)。系统在前端通过颜色编码实时展示水位状态≤2 红色预警、≤5 黄色、5 绿色。2.3 请假 ↔ 考勤的双向联动这是整个系统最精巧的联动逻辑。请假不只是记一条请假记录它要同时操作考勤、课时、打卡三张表创建请假: 1. 查找当天该学生所在班级的排课 2. attendance 表: 状态设为 leave 3. class_hour_extensions: 退还 2 课时 4. class_hour_consumption: 删除当天该生的打卡记录 删除请假: 1. attendance 表: 状态恢复为 present 2. class_hour_extensions: 反向扣减 2 课时 3. class_hour_consumption: 补写打卡记录关键代码示意// 请假联动核心逻辑functionhandleLeaveCreate(studentId:number,classId:number,leaveDate:string){// Step 1: 考勤状态变更db.run(UPDATE attendance SET status leave WHERE student_id ? AND class_id ? AND date ?,[studentId,classId,leaveDate]);// Step 2: 退还课时db.run(INSERT INTO class_hour_extensions (student_id, class_id, hours_adjusted, reason, adjusted_at) VALUES (?, ?, 2, 请假退还, datetime(now)),[studentId,classId]);// Step 3: 删除当天打卡记录db.run(DELETE FROM class_hour_consumption WHERE student_id ? AND class_id ? AND date ?,[studentId,classId,leaveDate]);// Step 4: 通知看板刷新sessionStorage.setItem(leaveUpdated,Date.now().toString());}最后一步的sessionStorage通信是一个巧妙的跨组件通知机制——请假模块写入标记Dashboard 组件通过轮询检测到变更后立即刷新数据避免了使用全局状态管理库的额外复杂度。三、全链路业务闭环从招生到退费的数据流一个学生在系统中的完整生命周期如下┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ 招生登记 │───→│ 学生分班 │───→│ 报课缴费 │───→│ 排课计划 │ └─────────┘ └────────┘ └────────┘ └────────┘ │ ┌────────────────┼────────────────┐ ↓ ↓ ↓ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 打卡上课 │ │ 请假退课时│ │ 课时预警 │ └──────────┘ └──────────┘ └──────────┘ │ │ │ ↓ ↓ ↓ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 工资核算 │ │ 退费管理 │ │ 续费催收 │ └──────────┘ └──────────┘ └──────────┘3.1 报课联动一次操作触发三张表当管理员为学生报课时系统自动执行创建 enrollment 记录关联学生、课程、课次、起止日期创建缴费记录应收金额 年级单价 × 课次数初始状态为欠费初始化课时额度在student_course_hours表中写入总课时// 报课联动functionenrollStudent(studentId:number,classId:number,totalSessions:number){// 1. 创建报课记录db.run(INSERT INTO enrollments (student_id, class_id, total_sessions, ...),...);// 2. 自动计算应收并创建缴费记录constunitPricegetUnitPrice(studentGrade);constreceivableunitPrice*totalSessions;db.run(INSERT INTO payments (student_id, class_id, amount, status, ...),[...,receivable,欠费]);// 3. 初始化课时额度consttotalHourstotalSessions*2;// 每课次 2 课时db.run(INSERT INTO student_course_hours (student_id, class_id, total_hours, ...));}3.2 优惠活动叠加多选累加减免缴费记录与优惠活动之间是多对多关系。系统设计了一个payment_promotions中间表CREATETABLEpayment_promotions(idINTEGERPRIMARYKEYAUTOINCREMENT,payment_idINTEGERNOTNULL,promotion_idINTEGERNOTNULL,discount_amountREALDEFAULT0-- 该活动为本笔缴费减免的金额);前端支持多选活动后端遍历选中活动累加减免金额// 优惠叠加计算functioncalculateDiscount(paymentId:number,promotionIds:number[]){lettotalDiscount0;for(constpromoIdofpromotionIds){constpromodb.query(SELECT * FROM promotions WHERE id ?,[promoId]);if(promo[0].type满减){totalDiscountpromo[0].discount_value;}elseif(promo[0].type折扣){totalDiscountreceivableAmount*(1-promo[0].discount_value/100);}}returntotalDiscount;}3.3 退费自动同步课程到期后的闭环当课程到期时系统自动检测并生成退费记录扫描所有enrollments中end_date 当前日期的记录计算剩余课时 总课时 - 已消耗 调整退费金额 剩余课次 × 单价 × 退费比例默认 0.8误课费 剩余课次 × 单价 × (1 - 退费比例)退费比例 0.8 的设计意味着机构保留 20% 作为行政管理费用这是教培行业的常见做法。四、SQLite WASM 内存模式轻量部署的工程取舍4.1 为什么不用 PostgreSQL / MySQL对于中小型培训机构通常 1-3 个校区、几百个学生部署和维护一个完整的数据库服务器是过度设计。SQLite 文件级数据库 WASM 内存加载的组合提供了一种零基础设施的方案无需安装数据库服务整个系统git clone下来即可运行内存读取零延迟数据库整体加载到 WASM 内存中查询速度等同内存操作单文件持久化退出时将内存数据写回data.db备份只需复制一个文件跨平台Windows bat/vbs 脚本一键启停Linux 同样支持离线部署4.2 DatabaseCompat 封装层sql.jsWASM 版和 better-sqlite3原生 C 绑定的 API 存在差异。为了在开发环境使用 better-sqlite3调试方便、生产环境使用 sql.js部署简单系统实现了一个兼容层// compat.ts - 统一两种 SQLite 驱动的接口classDatabaseCompat{privatedb:SqlJsDatabase|BetterSqlite3Database;run(sql:string,params?:any[]):void{// sql.js: db.run(sql, params)// better-sqlite3: db.prepare(sql).run(...params)}query(sql:string,params?:any[]):any[]{// sql.js: 解析 Statement 结果集// better-sqlite3: db.prepare(sql).all(...params)}// 统一的 exec / prepare / transaction 接口...}这种策略模式让上层路由代码完全不感知底层驱动的差异21 个路由模块中的db.run()和db.query()调用在两种模式下行为一致。4.3 Proxy 延迟初始化与种子数据数据库采用 Proxy 模式实现延迟初始化——启动时不立即加载 WASM而是在第一次数据库操作时才触发加载加载完成后自动执行排队中的挂起操作// 简化的 Proxy 延迟初始化示意constdbnewProxy({},{get(target,prop){if(!initialized){// 加载 WASM → 读取 data.db → 注入内存initializeDatabase();}returntarget[prop];}});// 种子数据自动注入functionseedIfEmpty(){constcountdb.query(SELECT COUNT(*) as c FROM users);if(count[0].c0){// 注入管理员账号、示例学生、课程、教室等executeSeedData();}}这确保了首次启动即可演示的体验——空数据库会自动注入一套完整的示例数据包括学生、教师、课程、排课、缴费记录等。4.4 内存模式的代价这个方案并非没有代价。需要明确了解的限制写操作延迟持久化内存中的修改只在进程退出前写回磁盘意外崩溃可能丢失数据不支持并发写入单进程模型无法多实例同时写入同一个 data.db修改数据库需停服手动修改 data.db 必须先停止后端进程否则重启后内存数据会覆盖外部修改时区敏感服务器默认 UTC所有日期操作必须显式设置TZAsia/Shanghai对于教培场景单校区、单管理员、非高并发这些限制完全可接受。但如果要扩展到多校区或多人协作场景则需要迁移到 PostgreSQL 或至少切换到 better-sqlite3 的文件模式。五、前端工程实践5.1 数据看板30 秒自动刷新的实时仪表盘Dashboard 是管理员进入系统的第一个页面承载了一眼看清全局的职责4 张统计卡片总学员数、在读学员数、月度新增、当天请假数6 张 ECharts 图表学段占比环形图、招生增长趋势、打卡趋势、请假趋势等实时时钟每秒刷新的北京时间自动刷新每 30 秒重新拉取全部数据// Dashboard 自动刷新 请假变更检测useEffect((){constintervalsetInterval(()fetchData(),30000);// 通过 sessionStorage 监听请假变更constcheckLeaveUpdate(){constlastUpdatesessionStorage.getItem(leaveUpdated);if(lastUpdateNumber(lastUpdate)lastRefreshTime){fetchData();}};constpollIntervalsetInterval(checkLeaveUpdate,2000);return(){clearInterval(interval);clearInterval(pollInterval);};},[]);5.2 DraggableModal可拖拽弹窗组件Ant Design 的 Modal 不支持拖拽。项目封装了一个DraggableModal组件支持拖拽移动 destroyOnClose确保弹窗关闭时表单状态完全清理DraggableModal title新增学生 open{visible} onCancel{handleCancel} destroyOnClose{true} // 关键关闭时销毁内部状态 Form form{form} Form.Item namename rules{[{ required: true }]} Input placeholder学生姓名 / /Form.Item {/* ... */} /Form /DraggableModaldestroyOnClose的重要性在于如果不清理用户关闭弹窗后再打开新增表单中会残留上次编辑时的数据。5.3 搜索防抖与全局搜索所有列表页的搜索输入框都实现了 300ms 防抖避免频繁请求后端。全局搜索更进一步——通过 Ant Design 的 AutoComplete 组件同时搜索学生、课程、教师三个维度结果分类展示前 6 条点击直接跳转对应管理页consthandleGlobalSearchuseCallback(debounce(async(keyword:string){const[students,courses,teachers]awaitPromise.all([api.get(/api/students,{params:{search:keyword}}),api.get(/api/classes,{params:{search:keyword}}),api.get(/api/teachers,{params:{search:keyword}}),]);// 分类合并结果...},300),[]);六、通知系统事件驱动的自动预警消息通知不是简单的发一条消息而是一个事件驱动的自动预警系统。系统根据业务状态自动触发通知触发事件通知类型通知内容学生剩余课时 ≤ 阈值预警“张三的数学课仅剩 2 课时请及时联系续费”缴费状态为欠费紧急“李四的英语课欠费 ¥3,200”缴费到账通知“王五的数学课缴费 ¥4,800 已到账”新生入学消息“新学员赵六已录入系统”上课打卡通知“三年级一班今日打卡 12 人”阈值可在系统设置中配置2/4/6/8/10 课时可选欠费提醒和打卡通知支持独立开关。通知数据存储在notifications表中前端通过铃铛图标 未读计数实时展示。七、部署方案从 Windows 一键启停到 Linux 离线部署7.1 Windows 方案start_all.bat # 双击启动全部 start_backend.vbs # VBS 静默启动后端无控制台窗口 start_frontend.vbs # VBS 静默启动前端 stop_all.vbs # 一键停止所有 Node 进程VBS 脚本通过CreateObject(WScript.Shell).Run以windowStyle0隐藏窗口方式启动 Node 进程实现无感知后台运行。7.2 Linux 离线部署# 安装依赖首次cdfrontendnpminstallcd../backendnpminstallnpmrun build# 启动需设置时区cdbackendTZAsia/Shanghainodedist/index.jscdfrontendnpmrun dev生产环境建议将前端npm run build的产物放到backend/public目录由 Express 直接提供静态文件服务这样只需启动一个 Node 进程。7.3 Vite 反向代理配置// vite.config.tsexportdefaultdefineConfig({server:{port:5273,proxy:{/api:{target:http://localhost:3001,changeOrigin:true,}}}});开发时前端跑在 5273 端口所有/api开头的请求被透明代理到 3001 端口的 Express 后端。Axios 实例配置了响应拦截器(res) res.data自动解包响应体业务代码直接拿到数据对象。八、数据库设计概览系统包含 24 张表按业务域分组┌─ 用户与权限 ──────────────────────────┐ │ users (用户账号 SHA256 密码哈希) │ │ config (系统配置键值对) │ └────────────────────────────────────────┘ ┌─ 教务核心 ─────────────────────────────┐ │ students (学生信息) │ │ parents (家长信息) │ │ teachers (教师信息) │ │ courses (课程定义) │ │ classes (班级关联课程教师) │ │ classrooms (教室) │ │ enrollments (报课学生↔课程绑定) │ │ class_students (班级↔学生关联) │ └────────────────────────────────────────┘ ┌─ 课时与考勤 ──────────────────────────┐ │ schedules (排课计划) │ │ student_course_hours (课时额度) │ │ class_hour_consumption (打卡消耗) │ │ class_hour_extensions (课时调整) │ │ attendance (考勤记录) │ │ student_progress (学习进度) │ └────────────────────────────────────────┘ ┌─ 财务 ────────────────────────────────┐ │ pricing_standards (年级定价标准) │ │ payments (缴费记录) │ │ payment_promotions (缴费↔优惠关联) │ │ refunds (退费记录) │ │ salaries (教师工资) │ │ promotions (优惠活动) │ │ debts (欠费记录) │ └────────────────────────────────────────┘ ┌─ 通知与成长 ──────────────────────────┐ │ notifications (消息通知) │ │ growth_info (成长档案-基本信息) │ │ growth_records (成长档案-记录条目) │ └────────────────────────────────────────┘九、效果展示登录界面数据看板报表中心课程管理课时管理营收管理数据管理十、总结适合什么场景尖角教务管理系统的定位非常清晰面向中小型教培机构的全栈 TypeScript 教务平台。它的核心价值在于全链路业务闭环从招生到退费13 个功能模块覆盖了教培机构日常运营的全部环节模块间通过精密的状态联动保持数据一致性。零基础设施部署SQLite WASM 内存模式 单文件数据库 VBS 一键启动脚本让不具备专业运维能力的机构也能自主部署和维护。前后端类型共享TypeScript 5.6 贯穿双端24 张表的接口定义在types/index.ts中统一维护编译时即可发现数据契约的不一致。可扩展的架构基础21 个路由模块、DatabaseCompat 兼容层、清晰的目录结构为后续功能迭代和多校区扩展预留了空间。如果你正在为教培机构的教务管理问题头疼或者想用 TypeScript 全栈方案构建一个中等规模的 B/S 系统这个项目值得一读。本次数据皆为测试数据。