课程评价问卷已经具备条件跳题、草稿恢复、动态必填和跨页缺答定位。填写者补齐了所有可见必填项,并不意味着答案一定能成为有效提交:评分题可能从旧草稿带来超出范围的数值,问卷规则升级后权重可能发生变化,一部分原本参与评分的题目也可能被新依赖条件隐藏。提交页如果只检查“有没有填写”,就可能把错误分数写入结果,还会让前台展示的综合得分与后台核算不一致。这一节在上一节的稳定题目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,分数携带参与权重,快照记录规则版本。后续无论增加导出结果、后台统计还是问卷多设备同步,都能沿用清晰的提交边界,而不会让界面显示的分数成为难以追溯的唯一依据。

相关资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐