从领域组件到考试入口:教务前端中的复杂交互抽象
业务系统里的前端工作,常常是从“先把这个页面做出来”开始:问卷页单独维护字段编辑,试卷页重新组织题目和分数,审核页继续堆叠配置与预览;考试入口又在验证码、设备识别和开考时间之间多了一层控制。
复制页面,或把判断先写进一个入口,短期内都很快。麻烦出现在后面:条目编辑、实时预览、数值计算和开考判断开始反复出现,改一处规则就得回头找好几处实现。
这个教务与考试前端里,表单设计器、试卷设计器、审核评分设计器,以及依赖设备标识和服务器时间的考试入口,承担的正是这部分复杂交互。它们把页面真正不同的部分留在页面,把反复出现的编辑协议和受控流程收进边界里。
我想讨论的是:哪些业务规则值得从页面代码中抽离?抽离之后,又怎样不让组件或流程入口变成另一个难以修改的大型黑盒?
本文是「教务前端架构实践」系列的一篇。系列以一个已脱敏的 Vue 3 教务管理前端为背景,分别讨论双端体验、导航与权限、会话治理和复杂业务交互。
领域组件抽象的对象是交互协议,不是视觉外壳
一个可复用按钮通常只需要稳定的外观与事件;而领域组件需要承接一段完整交互。对考试入口而言,领域边界则延伸到验证码校验、设备标识和开考时间判断。
以设计器为例,重复的并不只是拖拽区域:还有条目集合、当前选中项、局部编辑、预览与校验。考试入口也类似,除跳转逻辑外,设备标识生成、服务端校验和倒计时同样在多处出现。
所以我更愿意把领域抽象的对象,限定为“稳定的交互协议或流程协议”。
flowchart TB
P[业务页面] --> C[领域组件或流程契约]
C --> E[编辑状态<br/>选择、编辑、预览]
C --> X[考试入口状态<br/>验证码、设备标识、时间]
C --> A[领域动作<br/>计分、校验、跳转]
P --> D[业务差异<br/>接口、字段、规则]
页面仍处理业务差异:调用哪个领域接口、题目有什么特殊规则。领域组件或流程入口只收拢反复出现的编辑和校验流程。边界没分清之前,复用通常只是在搬运复杂度。
设计器的难点是深层状态,而不是组件层级
表单、试卷和审核评分设计器的共同难题,是编辑状态会跨越多个区域:左侧可能是条目列表,中间是编辑或预览区域,右侧是属性面板,弹窗中还可能有完整预览。
完全靠 Props 和事件逐层传递时,状态会穿过许多其实不关心它的组件。这里的三套设计器采用同一种基础组织方式:每套设计器各自定义 InjectionKey 和 Context,由顶层通过 provide 提供 items、current 及增删、选中等动作,深层组件再通过 inject 消费。
flowchart LR
R[设计器根组件] --> PC[provide DesignerContext]
PC --> L[条目列表]
PC --> E[属性编辑面板]
PC --> V[预览区域/弹窗]
L --> S[items + current]
E --> S
V --> S
这种做法把状态关在一棵设计器组件树里。不同设计器实例不会天然共享编辑数据;列表、属性面板和预览只需围绕同一份 Context 协作,不必为了传状态再多包几层组件。
三套设计器都要维护 items 和当前选中项,但没有因此合并成一个万能设计器。审核评分设计器多了质量特征与父级结构的关联查询,试卷设计器多了总分计算。这里保留这些差异,比把所有配置塞进同一个抽象里更好维护。
把三套 API 放在一起看,哪些是公共协议、哪些是领域扩展就很直观:FormDesigner 提供 items、current、addItem、removeItem 和 updateCurrent;AuditRatingFormDesigner 在此基础上提供 selectable、getAssociated(parentStructureId) 与 getSelectable(parentStructureId),用于质量特征与质量领域的关联;PaperDesigner 则增加 testPaperScores。它们各自使用独立的 InjectionKey,因此同页的多个设计器实例也不会串状态。
1 | // PaperDesignerContext.ts:省略类型别名后的核心状态 |
试卷配置页加载已有题目时,会先把接口 DTO 映射成带本地 key 和图片渲染地址的可编辑模型,再写入 items 并选中首题。后续编辑没有再绕一层统一更新函数:v-model 或直接修改 items 中题目、选项的引用就能生效。新增选项会直接 items.value[index].options.push(...);图片上传后,则按 key 找回目标题目并回填 URL。它们都在 Vue 的响应式数组里,预览和总分计算会跟着变。
这也暴露了当前契约的真实状态:三套 Context 虽然都声明了 updateItem,实现却仍为空。现有编辑由消费组件直接变更响应式条目,没有经由统一的“更新动作”;updateItem 目前不能被当作可用的封装 API。
sequenceDiagram
participant C as ConfigPaper
participant X as PaperDesignerContext
participant L as PaperTypeList
participant D as PaperDesign
participant P as Preview
C->>X: items = 映射后的题目;updateCurrent(首题)
L->>X: addItem(新题目)
X-->>D: 响应式 items / current
D->>X: updateCurrent(点击的题目)
D->>X: 直接修改题目、选项或图片字段
X-->>P: 同一份 items 用于预览
在计分场景中,数值精度本身就是业务规则
试卷设计器的总分不能只对 number 做累加。题目分数与系数相乘再汇总时,JavaScript 的二进制浮点数会留下看似很小、但不该出现在成绩结果里的误差。
试卷 Context 因此使用 Decimal.js:以 new Decimal(0) 初始化总分,对每道题执行 score × scoreCoefficient,再进行精确累加,最后才转换为数值展示。
1 | // 经过简化的计分逻辑 |
这套试卷 Context 把精度处理收在自己内部,而不是推给展示层:数值一旦参与资格、成绩、费用或统计结论,就不该等到界面展示时才临时处理。
不过,一个设计器用了 Decimal.js,不等于整条数值链路都安全。其他成绩或统计逻辑若仍直接使用浮点运算,还是得沿着业务规则继续核验。
考试入口的关键不是跳转,而是设备与时间两个边界
考试入口和普通表单提交不一样,它同时要面对两个问题:“谁从什么设备进入”,以及“现在是否真的可以作答”。这两件事若只靠浏览器本地状态,系统时间误差和多端使用都会留下空档。
考生输入验证码后,入口会通过 FingerprintJS 获取 visitorId,再读取或生成一个以考试发布 ID 为维度存储的本地 seed,组合成 visitorId.seed 形式的客户端标识,并随验证码一起发送给服务端校验。须知页会再次执行相同的生成与校验流程,确保进入答题页前仍经过服务端确认。
sequenceDiagram
participant U as 考生
participant E as 考试入口
participant S as 须知页
participant P as 答题页
participant API as 后端
U->>E: 输入 examCode
E->>E: visitorId + releaseId 维度的 seed
E->>API: POST verify/{releaseId}\nexamCode, examClientFingerprint
API-->>E: code + 考试信息(消费 examStartTime、currentTime)
alt 未到开考时间
E->>S: 新窗口携带 releaseId 与 code
S->>API: 再次 POST verify/{releaseId}
S->>API: GET /system/common/time
API-->>S: timestamp, currentTime, timezone
S->>S: timestamp + RTT / 2 校准并倒计时
S->>P: 倒计时结束后进入答题页
else 已到开考时间
E->>P: 直接进入答题页
end
P->>API: GET detailWithAnswers/{releaseId}
API-->>P: examInfo、questionnaire、studentInfo
P->>API: POST submitAnswer(草稿或最终交卷)
从前端代码只能确认“请求什么、消费什么、何时再次请求”。验证码校验调用 POST /system/examRelease/student/verify/{examReleaseId},以 examCode 与 examClientFingerprint 作为参数;前端在成功响应后消费 examStartTime 与 currentTime,须知页还会读取返回的 examInstructions。验证码有效期、指纹冲突、考试资格以及异常返回码的具体裁决都在服务端,不能从前端实现反推。
1 | // 考试入口:客户端标识和验证码一同交给服务端 |
答题页的契约则更明确:GET /exam/paper/detailWithAnswers/{examReleaseId} 返回 examInfo、questionnaire 和 studentInfo;其中前端使用 examStartTime、examDuration、allowedEarlySubmitTime 等字段显示时限和控制交卷按钮。提交时,页面把每道已作答题目序列化为 { questionId, questionType, value },再提交 examReleaseId、questionnaireId、answers 与 isSubmit。isSubmit: false 表示保存,isSubmit: true 表示最终交卷;后端如何校验题型答案、考试状态或重复提交,仍必须由后端接口的实际约束决定。
1 | const answers = Object.entries(formData).map(([questionId, answer]) => ({ |
设备指纹在这里仅作为交给服务端参与风控和业务校验的一个信号。本地 seed 放在 localStorage,可以被清理;项目也提供主考官重置客户端指纹的接口。它无法“绝对禁止换设备”,关键授权和考试资格仍得由服务端裁决。
时间边界也不能只看浏览器时钟。须知页调用 getServerTime() 时会记录请求开始和结束时间,用 serverTimestamp + requestTime / 2 估算当前服务器时间,再与开考时间比较:没到时间就倒计时,到点才进入答题页。答题页同样用这套半程补偿初始化计时器,并在窗口重新聚焦时刷新服务器时间。
1 | const startedAt = Date.now() |
半程补偿默认请求往返路径大致对称。它能减小本地时钟漂移和一般网络延迟的影响,却挡不住极端网络抖动,所以开考和交卷只能做到尽量贴近服务端时间。
复用越深,越要正视类型、响应式与测试成本
领域组件和受控流程会减少页面重复,也会把复杂度推到公共层。这个项目里,有三处不能因为“已经抽象了”就不再管。
设计器 Context 里仍有大量 Record<string, any>。它让不同业务条目更容易接入,也让 IDE 提示和重构保护变弱。三套 Context 声明的 updateItem 还是空实现,说明契约没有完全闭合。更稳妥的做法,是逐步为题目、表单项和评分结构补上泛型或判别联合类型,并补齐已声明动作的实际语义,而不是一次替掉所有 any。
设备标识只能作为风控信号,不能承担最终授权。visitorId 与本地 seed 的组合能帮助服务端识别同一场考试下的客户端;但清理浏览器数据、更换环境和指纹识别本身的不确定性,都要求后端保留重置、审计和人工处理能力。
时间校准和考试流程则需要端到端验证。至少要覆盖未到开考时间、网络延迟波动、窗口重新聚焦、允许提前交卷和考试结束自动提交。单元测试适合验证倒计时计算,浏览器自动化更适合走完从验证码到答题页的整条链路。
结语:组件化的终点不是更少的文件,而是更稳定的业务边界
从 CRUD 页面走向领域组件,要把已经在多个业务页里反复出现、并且规则相对稳定的交互协议抽出来,而不是把页面逻辑塞进一个“通用组件”。
设计器 Context 把深层编辑状态收在局部组件树里;Decimal.js 把计分精度写成显式规则;考试入口把设备标识、服务端校验和时间判断连成一段流程。
这些抽象也有代价:公共组件要有更严格的类型,考试流程要依赖可靠的服务端规则和端到端测试。缺了这些保障,领域组件和流程封装只会把复杂度换个地方堆起来。
系列文章:大型业务前端里,路由为什么不只是导航|一个仓库,如何同时承载 PC 后台与移动端|企业后台的登录态治理:如何处理 Token 刷新风暴与跨标签同步