985k5 是我这边一个中小学家校服务系统项目的内部代号,从立项到验收、再到上线后跟了一个学期的使用反馈,前后折腾了大半年。做之前我以为这类系统无非是“老师发条通知、家长收到消息”的简单 CRUD,真动手才发现,家校服务系统的难点从来不在功能多不多,而在三方角色的权限边界、消息的时间敏感度,以及不同学年阶段完全不同的使用节奏。
这篇文章既算项目复盘,也算给准备接同类项目的开发者一份可以照着操作的参考。我会按项目推进的实际顺序来写:先讲业务模型,再讲技术选型,然后是权限设计、核心模块实现,最后是部署上线后踩过的坑和修复方案。985k5 这个代号后面我会一直用,下文提到这个代号时,你把它理解成一个普通的中小学家校服务系统就行。
1. 先摸清业务再写代码:家校系统的三方角色与数据边界
1.1 三个角色三种视角
家校服务系统跟普通后台管理系统最大的不同,是它天然有三类使用对象,而且这三类人群的诉求差异很大。我一开始按常规后台的“管理员/普通用户”二分法去理解,结果被对接方直接打回。
家长端要的不是“管理功能”,而是“查看和确认”。家长打开系统最关心的就三件事:今天学校有没有通知、孩子的作业是什么、孩子今天到没到校。他们几乎不会去操作什么表单,顶多提交个作业、填个反馈。所以家长端设计的核心是信息流,要像刷手机一样轻松,操作层级越浅越好。
教师端则相反,老师是高频生产者。班主任要发班级通知、登记考勤、批注作业,科任老师要布置作业、催交作业。老师的使用场景往往是课间十分钟或者晚上批改时间,操作必须快、准、批量。凡是需要三步以上才能完成的操作,老师试用两次就会投诉。
学校管理者端要的是统计和治理:全校通知发了多少、每个班考勤率如何、作业提交率多少。这部分人的使用频率不高,但对数据准确性要求极高,而且他们经常要求导出 Excel。
1.2 数据边界:谁只能看见谁
这是985k5 项目里最早确认、也最不能妥协的一条规则:家长只能看见自己孩子的数据,老师只能看见自己任教班级的数据,校级管理者也不代表能看见所有老师的隐私信息。
我做过需求梳理后,把数据边界固定成了一张表格:
| 角色 | 通知可见 | 作业可见 | 考勤可见 |
|---|---|---|---|
| 家长 | 全校公告 + 所在班级 | 孩子所在班级,只能看自己孩子的提交状态 | 只有自己孩子的打卡记录 |
| 科任老师 | 全校公告 + 任教班级 | 任教班级的全部作业与提交 | 无,除非班主任授权 |
| 班主任 | 全校公告 + 本班 | 本班全部作业与提交 | 本班全部学生 |
| 校级管理者 | 全校范围 | 全校统计,不进入个人详情 | 全校统计视图 |
注意最后一行:校级管理者默认只能看统计聚合数据,不能随便点进某个学生的家庭信息。这个约束在需求评审时被反复强调,因为家校系统的数据涉及未成年人,宁可功能少一点,数据隔离也要做彻底。
1.3 跟着学年节奏走的功能优先级
家校系统还有一个特点,就是它的功能使用频率是随学期波动的。开学第一周,老师需要批量导入学生、调整班级、发开学通知;期中考试前后,成绩发布和家长会通知成为重点;期末阶段,评语、假期安全告知、下学期缴费提示集中爆发。
985k5 的项目排期就因此做了调整:先做基础主数据(学生、班级、家长绑定),再做通知和作业,再做考勤,最后做统计报表。这个顺序不是拍脑袋定的,而是遵循“先有稳定的人事数据,才有可靠的信息流转”这条逻辑。如果一上来先做花哨的报表,等真正录入学生数据时发现班级结构对不上,返工成本会非常痛苦。
2. 技术栈定型:为什么我坚持 Vue 3 + Pinia + Vite 这套组合
2.1 为什么不是小程序、不是 React
项目立项时,对接方问过一个问题:能不能直接做成小程序?我的回答是技术上可以,但业务上不建议。
小程序确实对家长友好,不用安装、入口浅,但它有几个绕不开的麻烦:一是学校没有统一的小程序运营主体,认证、类目审核都卡得很死;二是家校系统将来很可能要跟学校的其他系统(比如教务、门禁)做数据打通,一旦被小程序平台封闭生态绑住,后续扩展会很被动;三是每个班、每个年级的权限边界很细,小程序的开发调试链路比 H5 要繁琐不少。
React 和 Vue 之争就更简单了,我没有立场问题,只有团队上手成本问题。当时团队里几个前端对 Vue 2 都比较熟,与其让所有人半生不熟地切到 React,不如直接升级到 Vue 3。后来证明这个决定是对的:Vue 3 的组合式 API 写业务逻辑时非常顺手,尤其是要做权限逻辑复用和轮询这种带副作用的逻辑时,比 Vue 2 的选项式 API 清爽太多。
2.2 前端目录与后端边界约定
985k5 的前端采用 Vue 3 + Vite + Pinia + Vue Router + 组件库的方式,后端只提供 RESTful API,前端不做模板渲染,完全前后端分离。
前端目录结构如下,这个结构在项目后续维护中几乎没有调整过:
src/ ├── api/ │ ├── auth.js │ ├── notice.js │ ├── homework.js │ ├── attendance.js │ └── user.js ├── assets/ ├── components/ │ ├── NoticeCard.vue │ ├── HomeworkCard.vue │ ├── StudentPicker.vue │ └── UploadImage.vue ├── directives/ │ └── permission.js ├── layout/ │ └── MainLayout.vue ├── router/ │ └── index.js ├── stores/ │ ├── user.js │ ├── notice.js │ └── app.js ├── utils/ │ ├── request.js │ ├── date.js │ └── compress.js ├── views/ │ ├── LoginView.vue │ ├── notice/ │ ├── homework/ │ ├── attendance/ │ └── dashboard/ └── main.jsapi 目录单独拆出来,是我在项目里一直坚持的习惯。所有接口请求收口在 api 层,页面组件里不直接拼 URL,这样后端接口一旦调整路径,前端只需要改 api 目录下的对应文件,不用满项目翻。
2.3 基础工程初始化
初始化项目用的是 Vite 的官方模板:
npm create vite@latest sms-985k5 -- --template vue cd sms-985k5 npm install npm install pinia vue-router这里有个小提醒:Vite 默认模板比较精简,assets 里自带的 SVG logo 和生产无关,建议直接删掉,免得干扰目录整洁。另外,如果团队多人协作,最好在初始化后立刻配置 ESLint + Prettier,统一代码风格。我在 985k5 项目里一开始偷懒没配,后面大家提交的代码风格五花八门,review 时相当痛苦。
3. 权限与数据隔离:路由守卫、按钮级控制、后端复核缺一不可
3.1 角色表、路由守卫与动态菜单
权限制是家校系统的生命线,所以我把它的优先级提到业务模块之前。路由层我采用的方式是:静态注册全部路由 + 全局前置守卫按角色过滤。
import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } }, { path: '/', component: () => import('@/layout/MainLayout.vue'), children: [ { path: '', redirect: '/notices' }, { path: 'notices', name: 'Notices', component: () => import('@/views/notice/NoticeList.vue'), meta: { roles: ['admin', 'teacher', 'parent'] } }, { path: 'homework', name: 'Homework', component: () => import('@/views/homework/HomeworkList.vue'), meta: { roles: ['admin', 'teacher', 'parent'] } }, { path: 'attendance', name: 'Attendance', component: () => import('@/views/attendance/AttendanceList.vue'), meta: { roles: ['teacher', 'admin'] } } ] } ] }) router.beforeEach((to) => { const userStore = useUserStore() if (!to.meta.public && !userStore.token) { return { name: 'login', query: { redirect: to.fullPath } } } if (to.meta.roles && !to.meta.roles.includes(userStore.role)) { return { name: 'Notices' } } return true })这段代码看起来简单,但有一个容易忽略的点:刷新页面时 Pinia 里的用户信息会丢失,所以用户信息(token、role、permissions)必须做持久化。我用的方案是 Pinia 插件配合 localStorage 同步,刷新后先从本地恢复,再去后端校验 token 有效性。
3.2 按钮级控制:v-permission 指令
光有路由守卫还不够。同一个页面上,家长和老师看到的按钮就是不一样的。比如作业详情页,老师看到“布置作业”“删除作业”,家长只能看到“提交作业”。用 v-if 写判断会非常啰嗦,我封装了一个权限指令:
import { useUserStore } from '@/stores/user' const vPermission = { mounted(el, binding) { const userStore = useUserStore() const need = binding.value if (need && !userStore.permissions.includes(need)) { el.remove() } } } export default { install(app) { app.directive('permission', vPermission) } }模板里这样用:
<button v-permission="'homework:delete'">删除作业</button>使用指令时有个细节:directive 的 mounted 钩子里直接调用 el.remove() 会带来切换角色后按钮残留的问题。如果系统里有“同一账号切换角色”的场景,建议不要 remove,而是 display:none,并在 store 里维护一个响应式标记,用计算属性控制显隐。985k5 项目初期没有考虑这个,后来加角色切换功能时不得不返工,这是我想分享的一个教训。
3.3 数据权限:后端接口校验与前端兜底
前端权限做得再漂亮,也只是用户体验层面。真正决定数据安全的,永远是后端接口。家长端请求“获取孩子作业列表”时,后端必须根据 token 中的用户身份,先查出这个家长绑定了哪些学生,再只返回这些学生的数据,而不是让前端传一个 studentId 就拉全部数据。
我在前后端约定里明确了一条铁律:前端传任何学生 ID、班级 ID,后端都不直接信任,必须结合当前登录用户的权限范围做存在性校验。前端兜底逻辑是:万一接口被非法调用,页面也要有统一的错误处理,不能白屏静默失败。这个兜底主要是防止误操作,而不是安全手段。
4. 三大核心模块的实现拆解:通知、作业、考勤怎么落地
4.1 通知模块:发布、定向投放与已读统计
通知是家校系统里使用频率最高的功能。985k5 的通知表设计如下:
| 表名 | 字段 | 说明 |
|---|---|---|
| sms_notice | id, publisher_id, target_type, target_class_id, title, content, top_flag, publish_time | target_type 区分 全校/年级/班级/个人 |
| sms_read_log | id, notice_id, reader_id, read_time | 已读记录,一个用户一条 |
通知发布表单要做成“定向选择”,发布人先选范围(全校、某年级、某班、某学生),再填内容。这个看起来简单,实现时却有两个坑:
第一个坑是置顶逻辑。置顶的通知要在列表里排在最前,但不能让它一直霸占顶部。我们采用的方案是置顶按时间衰减,置顶通知超过 3 天自动取消置顶,不算太复杂,但比单纯布尔值是合理的。
第二个坑是已读统计。班主任发一条通知后,最关心“有几个家长没看”。已读统计不能靠前端轮询整张通知表去做,而是要在发布详情接口里返回总数和已读数。前端展示时用一个进度条式的组件呈现:已读 43/50,未读 7 人。这个功能上线后老师反馈极好,说比自己挨个发群消息确认高效得多。
4.2 作业模块:教师发、学生交、家长看的闭环
作业模块的核心逻辑是闭环:老师布置 → 学生提交 → 老师批改 → 家长查看。
作业表(sms_homework)和提交表(sms_homework_submission)是两张表。作业表存作业本身,提交表存每个学生的提交结果。为什么要拆两张表?因为一次作业对应几十个学生,每个学生一条提交记录,只有拆开才能高效统计“哪些人还没交”。
实现时要注意截止时间的展示逻辑。前端不能直接用家长手机本地时间判断“还剩多少小时”,因为有些家长手机时间会自己变快变慢,导致截止时间显示混乱。我的方案是后端返回 Unix 毫秒时间戳,前端在页面加载时先跟服务器对时,用对时后的时间差做倒计时渲染。这个小细节经常被忽略,但真出问题的时候家长会直接打电话投诉。
作业附件也要提前约定格式。老师可能上传图片、Word、PDF,学生提交作业最常见的是拍照上传。这就引出了后面第五章要讲的图片压缩问题。
4.3 考勤模块:打卡数据与班级报表
考勤模块的形态取决于学校现有的硬件条件。如果学校已经有考勤机或者门禁系统,家校系统的考勤模块更适合做“数据展示”;如果没有硬件,就只能做手动的到校确认。
985k5 采用的方式是混合模式:学校有门禁设备的班级,由设备自动上报数据;没有设备的班级,班主任每天早上在应用里批量确认。前端做的是一个简单的打卡确认面板,班主任可以按学生列表逐个操作,也可以一键“全勤”,再单独把异常状态的学生标出来。
考勤日报表的聚合查询要特别注意性能。一个班 40 人,一个月 20 个工作日,就是 800 条记录,看着不多,但如果后端在接口里做了多次循环统计,响应时间会很难看。我的建议是按天预聚合:每天结束后用定时任务生成当天的班级考勤统计,前端查报表的时候只查聚合表,不实时扫明细表。
5. 未读消息、文件上传、跨天打卡:几个容易翻车的功能点
5.1 未读数的轮询与缓存刷新策略
家长端最需要“实时感”的功能,是首页右上角的未读消息角标。真正做实时推送成本太高,家校系统用轮询就够。我封装了一个轮询 hook:
import { ref, onMounted, onUnmounted } from 'vue' export function useUnreadCount(fetchFn, interval = 60000) { const count = ref(0) let timer = null let polling = false const update = async () => { if (polling) return polling = true try { const value = await fetchFn() count.value = value } finally { polling = false } } onMounted(() => { update() timer = setInterval(update, interval) }) onUnmounted(() => { clearInterval(timer) }) return { count, refresh: update } }这里加了个 polling 锁。我在项目实测中遇到过一个问题:轮询接口响应慢,上一个请求还没回来,下一个又发出去,导致角标数字来回跳。加锁之后,只有上一次请求结束后才会开启下一次,从根源上避免了竞态。
轮询间隔我设置成 60 秒一次。开始觉得 30 秒更“实时”,但实际运行后发现,教师端发通知的瞬间,家长端最多延迟一分钟看到,对家校场景完全可以接受,而服务器压力和手机流量都少了一半。
5.2 图片上传要压缩,附件上传要限流
家长交作业的常态是手机拍照,现在的手机拍出来一张图动辄 5MB。如果不压缩,后端存储压力是一方面,家长上传时的流量和等待时间才是更大的问题。
我写了一个基于 canvas 的图片压缩工具,上传前先在前端最大压缩到 1280px 宽、quality 0.8:
async function compressImage(file, maxWidth = 1280, quality = 0.8) { const bitmap = await createImageBitmap(file) const ratio = Math.min(1, maxWidth / bitmap.width) const canvas = document.createElement('canvas') canvas.width = Math.round(bitmap.width * ratio) canvas.height = Math.round(bitmap.height * ratio) const ctx = canvas.getContext('2d') ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height) bitmap.close() return new Promise((resolve) => { canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality) }) }上传时要同时限制单个文件大小和单次上传数量。985k5 上线第一周就发生过一位家长在 3G 网络下上传 8 张原图把整个教室 WiFi 卡到瘫痪的情况,压缩加上限流之后,这类投诉基本消失。
5.3 跨天打卡与日期时区陷阱
考勤打卡最隐蔽的坑是跨天。有些家长晚上 12 点前后给孩子补打卡,后端如果按“自然日”结算,就会把这笔记录归到前一天或者后一天。我这边最后的方案是:打卡记录存储服务器时区的日期,同时记录打卡动作的 Unix 时间戳,统计报表按打卡动作时间所属的学校工作日划分。
日期格式化也统一交给一个工具函数处理,禁止在组件里到处 new Date()。项目里曾有人写了个 new Date('2024-09-01'),在部分手机上被解析成 UTC 时间,导致日期显示往前跳了 8 小时。后来所有日期解析都走统一的 dayjs 封装,再没出过这种乌龙。
6. 打包、部署与上线后的一周:实测中出现的问题和修复
6.1 打包体积优化与历史路由刷新 404
985k5 上线前打包分析显示,首屏 JS 超过 1.2MB。体积主要来自组件库和几个比较大的第三方库。优化手段有三条:路由懒加载、组件库按需引入、第三方库单独分包。
路由懒加载就是前面路由表里那种 () => import() 的写法,这能保证用户进首页时不加载全部页面代码。组件库按需引入后,体积立刻降了一截。第三方库(比如图表库、日期库)单独分包是为了利用浏览器缓存,避免业务代码一改动就导致库文件缓存失效。
还有一个部署层面的经典坑:单页应用在 history 模式下,刷新子路径会 404。Nginx 里必须配置 fallback:
location / { try_files $uri $uri/ /index.html; }这个配置我每次都提醒自己团队里的新人,因为不配的话,家长端一刷新页面就报错,但首屏打开又是好的,很容易误判成偶发 bug。
6.2 低版本手机浏览器的兼容问题
家校系统有一个绕不开的现实:家长的手机型号极其分散,有些人用的还是几年前的千元机,内嵌浏览器版本很低。
实测中遇到两个高频问题:一是可选链操作符(?.)在低版本 WebView 上直接语法报错,Vite 构建时的 target 要设置成 'es2015' 而不是默认的 'modules',必要时配合 @vitejs/plugin-legacy;二是 CSS 变量(var())在部分老旧浏览器上不生效,样式会全部乱掉。如果想省心,建议全局样式少用 CSS 变量,能用普通色值就用普通色值。
兼容性测试不能只在开发者工具的手机模拟器里做。我的做法是找了三个不同价位的安卓手机加一部旧 iPhone 组成测试矩阵,每轮发版前手动过一遍核心流程:登录、看通知、交作业、打卡。
6.3 上线后第一周的真实现场
上线第一周,我整理了一份问题清单,这些问题在开发和测试阶段都没有暴露,全是在真实用户手里冒出来的:
| 问题 | 原因 | 修复方式 |
|---|---|---|
| 家长反馈收不到通知 | 部分手机系统对 H5 有后台省电策略,页面切后台后轮询被冻结 | 页面从后台切回时强制刷新未读数 |
| 老师上传附件失败 | 附件使用了一个不常用的格式,后端未做类型识别 | 完善后端 MIME 白名单 |
| 考勤统计和人工记录对不上 | 补打卡记录的时间归属混乱 | 重写打卡时间归属逻辑 |
| 低版本手机白屏 | 可选链语法不被支持 | 构建 target 改为 es2015 并引入 legacy 插件 |
第一周也是最容易收到用户冷嘲热讽的阶段,因为家长群体里消息传播极快,一个班出现一个问题,整个年级群就都知道了。我的经验是准备一个极简的“问题收集模板”,让班主任能直接拍照反馈,而不是让班主任听家长转述再转达给开发。
6.4 最后再分享一点经验
985k5 这个项目我最大的体会是:家校系统的技术难点其实不在技术本身,而在需求方随时会变的使用场景。今天老师说“能不能一键提醒未交作业的家长”,明天家长说“能不能别把成绩单直接展示给孩子看”,这些需求每一个都不大,但堆在一起,就对系统的扩展性提出了要求。
所以我最后建议做同类项目的开发者:权限模型一定要从第一天就设计好,数据表里学生和家长的绑定关系一定要支持多孩子,通知模块一定要做成分层结构。这三件事在需求不明确的时候可能会显得过度设计,但一旦上线,你会发现它们全是救命设计。