HarmonyOS问卷引擎连载06:提交摘要与评分规则异常拦截【共创稿事节】
课程评价问卷已经具备条件跳题、草稿恢复、动态必填和跨页缺答定位。填写者补齐了所有可见必填项,并不意味着答案一定能成为有效提交:评分题可能从旧草稿带来超出范围的数值,问卷规则升级后权重可能发生变化,一部分原本参与评分的题目也可能被新依赖条件隐藏。提交页如果只检查“有没有填写”,就可能把错误分数写入结果,还会让前台展示的综合得分与后台核算不一致。这一节在上一节的稳定题目ID与分组进度基础上建立评分明细、校验门禁和可追溯提交快照。

提交检查应以当前可见题为起点
前五篇的问卷模型已经明确:答案按题目ID保存,规则引擎负责解析依赖图并剔除隐藏题答案,分组页负责定位缺答。提交时不能直接遍历原始题库算分,否则填写者选择“未安排实践项目”后,隐藏的实践评分可能继续参与统计。正确顺序是取得当前问卷规则版本、恢复并规整答案、重算可见集合,再分别执行必填检查与评分约束检查。两类检查返回结构化错误,才能让上一节的缺答定位按钮直接复用。
在SurveyFlow工程里保留model/Question.ets、rules/VisibilityEngine.ets与service/GroupProgress.ets,增加model/ScoreRule.ets、service/ScoreCalculator.ets、service/SubmissionGate.ets、service/SubmissionRepository.ets以及pages/SubmissionReviewPage.ets。问题ID继续使用Q03、Q05、Q07这样的稳定业务身份,分组名称只是显示字段;百分比权重由当前规则版本承载,避免把某个百分数写死在页面上。
以课程内容、教学方法、实践体验三个评分维度为例,权重分别为40%、35%、25%。示例答案4.5、4、4时,加权结果为4.2分;若旧草稿把实践体验写成6,则应该标明Q07评分超出1~5区间并阻止提交,不能先把6截成5再悄悄参与计算。改成允许的小数精度或使用半星,必须在规则文件中明确约定。
export interface ScoreRule {
questionId: string;
dimension: string;
weight: number;
minimum: number;
maximum: number;
}
export interface ScoreConfig {
version: number;
rules: ScoreRule[];
}
export function checkScoreConfig(config: ScoreConfig): string[] {
const errors: string[] = [];
const ids: Set<string> = new Set<string>();
let weightTotal: number = 0;
config.rules.forEach((rule: ScoreRule) => {
if (ids.has(rule.questionId)) errors.push(`${rule.questionId}重复配置`);
ids.add(rule.questionId);
if (!Number.isFinite(rule.weight) || rule.weight <= 0 || rule.weight > 1 ||
!Number.isFinite(rule.minimum) || !Number.isFinite(rule.maximum) ||
rule.minimum >= rule.maximum) {
errors.push(`${rule.questionId}评分规则不合法`);
}
weightTotal += rule.weight;
});
if (config.version < 1 || Math.abs(weightTotal - 1) > 0.000001) {
errors.push('规则版本或权重总和异常');
}
return errors;
}
配置检查与答案检查需要分开。权重合计不是100%时,不要通过自动除以当前总权重来掩盖配置错误。只有规则本身合法,并且某个维度因条件依赖确实不可见,才允许在“当前可见评分维度”之间重新归一化权重;同时把实际参与的维度列表放进提交摘要。这样审核结果时可以看出综合分为何与上一版本不同。
数值有效性不能靠界面控件兜底
评分控件一般把星级限制为五颗,但数据可能来自旧草稿、同步缓存或规则变更前保存的答案。点击按钮时再次验证原始字符串是必要的;更重要的是,空字符串和不合法数值不能在转换中变成零分。Number('')会得到0,直接调用会把缺答伪装成有效输入。解析器先判空,再进行有限数、上下界和步长检查,并让错误带回稳定题目ID。
export interface ScoreIssue {
questionId: string;
reason: string;
}
export function readScore(raw: string | undefined, rule: ScoreRule): number {
if (raw === undefined || raw.trim().length === 0) {
throw new Error('评分缺失');
}
const value: number = Number(raw);
if (!Number.isFinite(value) || value < rule.minimum || value > rule.maximum) {
throw new Error(`评分范围应为${rule.minimum}~${rule.maximum}`);
}
const twice: number = value * 2;
if (Math.abs(twice - Math.round(twice)) > 0.000001) {
throw new Error('评分须按0.5分递增');
}
return value;
}
export function collectScoreIssues(
active: ScoreRule[], answers: Map<string, string>
): ScoreIssue[] {
const issues: ScoreIssue[] = [];
active.forEach((rule: ScoreRule) => {
try {
readScore(answers.get(rule.questionId), rule);
} catch (error) {
issues.push({ questionId: rule.questionId, reason: (error as Error).message });
}
});
return issues;
}
这里允许0.5分为单位,实际产品若使用整星可以把步长规则调为整数检查。不要把“数字可解析”当作“业务上合法”,因为负数、无穷大及超过最大值都会通过某些宽松的输入路径。进入下一阶段以前,应先确认每个参与评分的题目存在于当前可见集合;隐藏题即使留有草稿值,也不应在结果中产生贡献。

评分摘要与错误列表用同一个快照
分数计算必须在规则和答案都完成检查后进行。如果校验失败,提交预览可以保留“最近一次有效摘要”供比较,但需要清晰标识它不是当前答案的实时得分。比如屏幕上保留上次4.2/5的摘要,而当前Q07已经被填成6/5,提交按钮应处于阻断状态。尤其不能一边展示4.2分,一边把6这个错误数值写入新的提交记录。
可见评分维度的总权重有时小于1,例如实践环节被条件隐藏。这里在有效参与的维度中计算归一化加权平均;同时保存权重覆盖范围,供后台解释。若没有任何有效评分维度,则不要返回零分假装“极差”,而应返回配置问题,要求问卷维护人员处理。
export interface ScorePart {
questionId: string;
dimension: string;
value: number;
originalWeight: number;
contribution: number;
}
export interface ScoreSummary {
total: number;
activeWeight: number;
parts: ScorePart[];
}
export function calculateSummary(
active: ScoreRule[], answers: Map<string, string>
): ScoreSummary {
const issues = collectScoreIssues(active, answers);
if (issues.length > 0) throw new Error(issues[0].reason);
const totalWeight = active.reduce((sum: number, r: ScoreRule) => sum + r.weight, 0);
if (totalWeight <= 0) throw new Error('当前缺少有效评分维度');
const parts: ScorePart[] = active.map((rule: ScoreRule) => {
const value = readScore(answers.get(rule.questionId), rule);
return { questionId: rule.questionId, dimension: rule.dimension,
value, originalWeight: rule.weight,
contribution: value * rule.weight / totalWeight };
});
const total = parts.reduce((sum: number, p: ScorePart) => sum + p.contribution, 0);
return { total: Number(total.toFixed(2)),
activeWeight: totalWeight, parts };
}
计算值和展示值也应拆开。toFixed(2)用于呈现与摘要存储,内部核算过程保持数值精度;后台重新计算时仍从原始答案和规则版本开始,不从UI上的四舍五入字符串反推。评分项可能有多个属于同一维度,届时要先计算维度内的得分,再在维度间分配权重,而不是把每一题的权重机械叠加;当前场景每个维度恰好对应一道代表题,因此可以直接使用上面的简化模型。
提交门禁先核对身份再写入
上一节已经提供visibleQuestions及isAnswered,可以继续得到当前可见必填题的缺答ID。提交门禁同时检查题库依赖配置、草稿与规则版本是否匹配、评分配置是否合法,以及评分范围是否有效。任一检查失败都应返回可定位的错误对象;仅在所有条件满足时才允许形成提交快照。这样“缺答定位”与“评分异常定位”复用相同的ID通道,不需要在页面额外维护一套下标。
export interface SubmissionProblem {
questionId: string;
message: string;
}
export interface SubmissionCheck {
accepted: boolean;
problems: SubmissionProblem[];
summary?: ScoreSummary;
}
export function examineSubmission(
visible: Question[], answers: Map<string, string>, config: ScoreConfig
): SubmissionCheck {
const problems: SubmissionProblem[] = [];
visible.filter((q: Question) => q.required && !isAnswered(q, answers))
.forEach((q: Question) => problems.push({ questionId: q.id, message: '必填项为空' }));
checkScoreConfig(config).forEach((reason: string) =>
problems.push({ questionId: '', message: reason }));
const shown: Set<string> = new Set<string>(visible.map((q: Question) => q.id));
const active = config.rules.filter((rule: ScoreRule) => shown.has(rule.questionId));
collectScoreIssues(active, answers).forEach((issue: ScoreIssue) =>
problems.push({ questionId: issue.questionId, message: issue.reason }));
if (problems.length > 0) return { accepted: false, problems };
try {
return { accepted: true, problems: [], summary: calculateSummary(active, answers) };
} catch (error) {
return { accepted: false,
problems: [{ questionId: '', message: (error as Error).message }] };
}
}
实际工程中还需把草稿的ruleVersion与当前config.version作显式比较。若版本不同,先执行第三篇建立的草稿迁移,再重新运行依赖图与所有校验;不能看到评分数值正好还在1~5之间就默认旧规则仍然有效。可见集合应来自第二篇的依赖拓扑排序与第一篇的隐藏答案清理,不能由提交页自己凭单层条件再判断一次。
提交快照采用稳定的问卷实例ID、规则版本、可见问题ID列表以及有序答案条目。Map直接做JSON序列化不会形成预期的完整键值结构,所以要转换为显式对象数组,再按题目ID排序。将结果交给本地仓库写入时,界面先显示“提交中”,等待持久化成功后再切换为“已保存待处理”;如果后续接入服务端,只有收到服务端明确确认才能切换为“提交完成”。
export interface AnswerEntry { questionId: string; value: string; }
export interface SubmissionSnapshot {
formId: string;
ruleVersion: number;
visibleIds: string[];
answers: AnswerEntry[];
score: ScoreSummary;
createdAtMs: number;
}
export function makeSnapshot(
formId: string, config: ScoreConfig, visible: Question[],
answers: Map<string, string>, check: SubmissionCheck
): SubmissionSnapshot {
if (!check.accepted || check.summary === undefined) {
throw new Error('当前答案存在需要处理的问题');
}
const ids = visible.map((q: Question) => q.id).sort();
const entries: AnswerEntry[] = ids.filter((id: string) => answers.has(id))
.map((id: string) => ({ questionId: id, value: answers.get(id) ?? '' }));
return { formId, ruleVersion: config.version,
visibleIds: ids, answers: entries, score: check.summary,
createdAtMs: Date.now() };
}
为避免连续点击提交生成两份重复记录,可以让提交按钮进入互斥状态,并给存储层传入业务幂等键formId:ruleVersion:draftRevision。幂等键应由数据层校验,而非依赖UI按钮一瞬间的禁用状态。异常恢复时先加载已存在的提交快照,若键相同则返回原记录;若草稿在保存期间发生变化,应重新计算新快照,不得用旧分数混搭新答案。

调试从异常答案进入,而不是只看总分
准备当前版本的三个评分规则和四个问卷分组,先用4.5、4、4的答案核查理论加权值4.2;然后把Q07改成6,确认错误列表指向Q07且新摘要生成被拦截。接着测试空字符串、非数字、2.3这种不满足半分步长的值、重复权重ID、总权重偏离1、条件隐藏Q07以及草稿规则版本落后等输入。每一类问题都应有明确的定位入口和处理原因,不能统统显示“提交失败”。
HiLog定位时可记录规则版本、当前可见问题数量、活动评分维度、首个错误题ID与门禁状态,不需要记录问卷中的完整个人信息。列表中用文字说明“评分超出范围”,而不是只让一个输入框变红;返回填写页时将错误对象的questionId交给上一节的分组导航,先展开正确组,再按稳定ID滚动至对应行。多端适配时,平板可把评分摘要和问题列表并列展示,手机采用纵向列表,底层校验函数保持一致。
这套提交管线最重要的收获,是把“有答案”“答案符合当前规则”“可以计算分数”“可以生成提交快照”分成四个独立条件。数据由可见题集合出发,错误回到稳定问题ID,分数携带参与权重,快照记录规则版本。后续无论增加导出结果、后台统计还是问卷多设备同步,都能沿用清晰的提交边界,而不会让界面显示的分数成为难以追溯的唯一依据。
相关资料
更多推荐

所有评论(0)