课程评价问卷的提交链路已经能够检查动态必填、评分取值和权重规则。新的问题出现在结果页:某门课程的A班收到了十二份有效评价,B班只有三份。如果两个班级都显示精确平均分、各星级人数甚至逐题分布,人数较少的班级很容易通过已知信息猜出个别填写者的评价。简单地把姓名字段从结果表删掉,仍不能解决小样本统计的识别风险。

上一节交付的是“这份答案能否参与评分”的摘要,本节沿用其最终有效提交记录,构建只面向聚合结果的统计层。最基础的显示规则是:某组有效样本数未达到业务设定的阈值时,公开页面隐藏该组均值与分布。阈值在示例中取五份,它是产品展示策略,不是系统权限;管理员和面向公众的报告应使用不同的数据投影,界面隐藏字段也不能替代服务端访问控制。

分组结果与小样本隐藏

统计入口只接受有效提交

前六篇依次完成了条件跳题、依赖拓扑、旧草稿迁移、动态必填、缺答定位和评分规则拦截。这个顺序非常重要:统计不应重新猜测某道题当时是否可见,也不应直接遍历尚未提交的草稿。评分服务先根据发布版本核定答案,产出固定的分数和提交状态;统计层接收的是已经通过业务校验的记录。

目录在原工程基础上增加model/ScoreRecord.ets、service/GroupAggregator.ets、service/ResultProjection.ets以及pages/ReportPage.ets。前者描述数据契约,聚合器做纯计算,结果投影决定哪些字段可以进入展示页面。这样教学负责人调整最小样本数、增加统计分组,或者为平板改造列表布局时,原始提交记录与算法不会被页面逻辑意外修改。

对课程评价而言,分组维度可以是班级、课程或教学环节,但每次统计必须先限定同一个问卷版本、同一个有效时间窗口。混合不同版本的评分规则会让平均分失去可比性;若新版将某道满意度题的权重改动,不能仅凭“满分都是五分”就把旧版和新版结果并列计算。本文选择固定的surveyVersion=3作为统计口径,分数组件使用上一节已经核准的normalizedScore。

数据模型同时保存提交身份和版本

同一份结果可能因为网络重试重复到达汇总层,所以需要稳定的提交标识。若后端允许撤回或更正提交,聚合前应按submissionId选择修订号最大的记录,然后再过滤状态与分数。只按数组长度计数,会让重复请求看起来像新增了受访者。下面的数据结构刻意把组ID与提交身份分开,UI拿到的结果模型中不携带填写者的姓名、联系方式或单题正文。

// model/ScoreRecord.ets
export interface ScoreRecord {
  submissionId: string;
  revision: number;
  groupId: string;
  surveyVersion: number;
  status: 'ACCEPTED' | 'REJECTED';
  normalizedScore: number;
}

export interface GroupReport {
  groupId: string;
  validCount: number;
  visible: boolean;
  mean: number | null;
  histogram: number[];
}

export function isAccepted(record: ScoreRecord, version: number): boolean {
  return record.status === 'ACCEPTED' &&
    record.surveyVersion === version &&
    record.groupId.length > 0 &&
    Number.isFinite(record.normalizedScore) &&
    record.normalizedScore >= 1 && record.normalizedScore <= 5;
}

normalizedScore在本例中约定为一至五分的已核准数值。与题目评分相关的计算、无效选项拦截和权重校验仍属于上一篇的提交层,统计层不能悄悄修正明显越界的分数,否则同一份提交在不同页面上可能出现两种结果。统计层遇到不符合契约的记录,应把它放入待核对范围,并记录对应提交ID,而不是把异常值当作零分拉低平均数。

同一组内可能出现多次改动。为了避免用户的旧分数和新分数都进入汇总,先按提交ID收敛,再取最近一次修订。聚合的顺序固定为去重、选版本、核定有效性、分组、计算、投影。阈值判定必须放在有效样本过滤之后,不能先用总提交数决定是否显示结果。

用单次聚合计算均值与分布

统计页需要总评分和星级分布两个结果,但不应为每个指标各遍历整张提交表。聚合时将符合口径的分数按组累加,再得到均值和直方图。为避免人为误判,下面把样本阈值写成参数;UI与服务端都应使用同一份配置。分布按四舍五入至一到五星的示例口径计数,平均分仍使用原始核准得分计算,两种口径在报告说明中分别标注。

// service/GroupAggregator.ets
import { ScoreRecord, GroupReport, isAccepted } from '../model/ScoreRecord';

export function aggregateGroups(input: ScoreRecord[],
  version: number, minCount: number): GroupReport[] {
  if (!Number.isInteger(minCount) || minCount < 2) {
    throw new Error('样本阈值配置有误');
  }
  const latest = new Map<string, ScoreRecord>();
  for (const record of input) {
    const old = latest.get(record.submissionId);
    if (!old || record.revision > old.revision) {
      latest.set(record.submissionId, record);
    }
  }
  const buckets = new Map<string, number[]>();
  for (const record of latest.values()) {
    if (!isAccepted(record, version)) {
      continue;
    }
    const values = buckets.get(record.groupId) ?? [];
    values.push(record.normalizedScore);
    buckets.set(record.groupId, values);
  }
  const reports: GroupReport[] = [];
  for (const [groupId, values] of buckets.entries()) {
    const visible = values.length >= minCount;
    const sum = values.reduce((total: number, n: number) => total + n, 0);
    const histogram: number[] = [0, 0, 0, 0, 0];
    if (visible) {
      for (const value of values) {
        const star = Math.max(1, Math.min(5, Math.round(value)));
        histogram[star - 1] += 1;
      }
    }
    reports.push({ groupId, validCount: values.length, visible,
      mean: visible ? sum / values.length : null, histogram });
  }
  return reports.sort((a: GroupReport, b: GroupReport) =>
    a.groupId.localeCompare(b.groupId));
}

用十二份有效记录计算A班,假设均值为4.2,报告可展示平均分与五档分布。B班只有三份,即使这三份分数都有效,公开报告也应把均值和直方图置为不可展示状态。这里保留validCount给管理端核查;对普通查看者,可以进一步只显示“样本不足”,而不暴露精确人数。隐藏是数据投影的一部分,不应仅靠把文字颜色调浅或遮盖图表实现。

同一班级如果没有符合条件的提交,聚合器不会自然产生该组记录。页面如果需要保留完整的班级目录,可以用课程主数据与聚合结果做左连接,对缺失组生成validCount=0的空报告。不要把零份样本当作均值为零,也不要把图表中的五个空柱解释成“每个评分档位都无人选择”:这类状态属于无数据,而不是观众给了低分。

服务端投影与页面状态分层

业务端应保留一个独立的结果投影函数,面向公开报告只输出允许展示的字段。这样在页面缓存、导出或分享统计摘要时,都会取到同一份受控数据。管理端可以查看有效记录数帮助排查,但也需要明确角色授权,避免通过不断调整班级筛选条件反推出单份反馈。下面给出一个简洁的投影对象,保留canShow而不把所有内部细节传给界面。

// service/ResultProjection.ets
import { GroupReport } from '../model/ScoreRecord';

export interface PublicGroupRow {
  groupId: string;
  label: string;
  canShow: boolean;
  meanText: string;
  histogram: number[];
}

export function toPublicRows(reports: GroupReport[]): PublicGroupRow[] {
  return reports.map((report: GroupReport) => ({
    groupId: report.groupId,
    label: report.visible ? '已满足展示条件' : '样本不足',
    canShow: report.visible,
    meanText: report.visible && report.mean !== null
      ? report.mean.toFixed(1) : '—',
    histogram: report.visible ? report.histogram.slice() : []
  }));
}

小样本阈值不是一项可以任意放宽的页面开关。多维交叉分组,例如“某班级+某课程+某个学习小组”,可能使原本满足要求的十二人群组被切成多个三人组。还要防止通过“全班结果减去某小组结果”的差分查询推算人数过少的单元。实际运营中应限制细分层级,统一统计周期,对敏感分组采用更高阈值,并为导出功能设置独立的访问控制与审计。

ArkUI 列表只消费聚合行

页面不应接触原始提交记录,而应从受控的统计服务取得PublicGroupRow[]。用@State保存可显示行,以固定groupId作为ForEach键,动态更新统计周期时列表的身份仍稳定。对于可展示组渲染均值,对于不足阈值的组显示占位文案;不能在后者的调试信息或可复制文本中残留被隐藏的分数。下例省略了图表绘制,只保留状态分支和主要字段。

// pages/ReportPage.ets 的页面主体
import { PublicGroupRow } from '../service/ResultProjection';

@Entry
@Component
struct ReportPage {
  @State rows: PublicGroupRow[] = [];
  @State loading: boolean = false;
  @State errorText: string = '';

  build() {
    Column({ space: 12 }) {
      Text('课程评价统计').fontSize(22).fontWeight(FontWeight.Bold)
      if (this.loading) {
        Text('正在载入汇总结果')
      }
      if (this.errorText.length > 0) {
        Text(this.errorText).fontColor('#B33434')
      }
      List({ space: 12 }) {
        ForEach(this.rows, (row: PublicGroupRow) => {
          ListItem() {
            Column({ space: 8 }) {
              Text(row.groupId).fontSize(19)
              Text(row.canShow ? `平均分 ${row.meanText}` : row.label)
                .fontColor(row.canShow ? '#166AC6' : '#916B29')
            }.padding(16).width('100%')
          }
        }, (row: PublicGroupRow) => row.groupId)
      }.layoutWeight(1)
    }.padding(16).height('100%')
  }
}

统计服务与页面预览

分组显示阈值与匿名结果

管理端可以同时看到A班十二份有效记录和B班三份有效记录,以核对阈值是否正确应用;面向填写者的统计页面则显示A班均值与分布,对B班仅给出“样本不足”。这样的权限差异应由数据接口返回的投影模型决定,不能让普通查看者只要更改页面状态便看到被保护的字段。

回归路径从规则版本到统计投影

排查“分组人数比提交页多”时,先核对submissionId去重是否在有效性过滤之前执行,随后检查被更正记录的修订号与最终状态。排查“平均分突然变化”时,先确认查询窗口和问卷版本是否被混合,再比较上一节输出的核准评分规则版本。排查“隐藏组仍有柱状图”时,重点查看接口响应里是否已经剔除直方图,而不是只看页面是否隐藏组件。

可以准备包含重复提交、撤回更正、规则版本不一致、评分越界与小样本组的输入集合,逐项核对计算结果。对A班构造十二条有效评分,B班构造三条有效评分,按阈值五计算时,A班可以展示,B班返回空分布。再把B班增加到五条,检查统计投影是否由隐藏切换为显示。公开接口的监控字段使用组数、阈值和统计窗口即可,避免记录原始评论全文或单份评分。

官方资料可参考HarmonyOS文档中心、ArkTS列表与分组列表和https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch状态观察。示例的数据聚合、最小展示阈值及公共投影均属于应用业务逻辑,ArkUI承担列表呈现与状态响应;选用不同的SDK时,应在对应版本的文档中复核接口可用情况。

到这里,问卷的完整链路从条件题的答案有效性延伸到了结果可见性:提交前确认每条记录的评分合法,提交后在统一版本与口径下聚合,再依据样本阈值生成受控的统计页面。下一篇可以围绕跨设备结果页布局与统计快照缓存继续完善,避免大屏与手机端分别计算出不一致的报告。

Logo

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

更多推荐