课程评价问卷做到动态必填、草稿恢复以后,一个新的问题开始显现:问卷内容一多,提交前的“还有两道必填未答”并不能帮助填写者找到那两道题。上一节已经规定,只有当前可见且必填的题目参与提交检查;这一节进一步把问题定位到分组,再定位到题目。页面需要保留基础信息、教学评价、实践环节和总体反馈四个分组,同时保证条件跳题改变可见性时,分组进度与定位索引随之更新。

分组导航与缺答定位

题目身份不能跟随页面顺序变化

问卷最初只有几个题目时,直接通过下标找到第几题似乎很自然。后来加上条件跳题和问卷版本迁移,同一题可能从第三位变成第五位,或者整个分组临时隐藏。把草稿答案、错误提示和滚动位置全部绑定数组下标,任何一次规则变化都可能定位错题。这里继续沿用前四篇的稳定题目ID,并新增groupId作为导航归属。

组织工程时,把校验函数与UI放在不同文件:model/Question.ets保留题目与依赖规则,service/VisibleQuestionResolver.ets计算当前可见集合,service/GroupProgress.ets生成每组进度和缺答索引,pages/SurveyPage.ets负责列表与跳转。题目的顺序可以由问卷规则版本提供,但同一个ID在版本升级时应有明确的迁移映射。

先定义分组、题目和答案模型。下面第一段代码解决“当前应该统计哪些题目”。它不去修改原始题库,而是从完整题库派生可见问题;前面章节的条件依赖规则在同一个函数里得到复用。

import { QuestionRule } from '../model/QuestionRule';
import { checkDependencies } from '../rules/DependencyGraph';
import { rebuildSurvey } from '../rules/VisibilityEngine';

export interface Question extends QuestionRule {
  groupId: string;
  required: boolean;
  order: number;
}

export type AnswerMap = Map<string, string>;

export function visibleQuestions(questions: Question[], answers: AnswerMap): Question[] {
  const report = checkDependencies(questions);
  if (report.errors.length > 0 || report.unresolvedIds.length > 0) {
    throw new Error('题目依赖配置存在错误');
  }
  const snapshot = rebuildSurvey(questions, report.orderIds, answers);
  const visible = new Set<string>(snapshot.visibleIds);
  return questions.filter((q: Question) => visible.has(q.id))
    .sort((a: Question, b: Question) => a.order - b.order);
}

export function isAnswered(q: Question, answers: AnswerMap): boolean {
  const value = answers.get(q.id) ?? '';
  return value.trim().length > 0;
}

这里继续使用第二篇建立的依赖图检查和拓扑重算函数,避免把多级规则退化为单层条件判断;Question在原有QuestionRule基础上增加分组和必填字段。答案仍由Map按题目ID保存,字符串承接单选题与文本题的基本答案。多选题需要在数据层使用独立的数组字段,或把答案标准化后再交给isAnswered;不能简单地把空数组序列化成字符串[]就认为用户已经完成回答。条件跳题导致某个子题隐藏时,要调用此前的清理逻辑移除旧答案,避免提交快照中残留不可见题目的评分。

每个分组展示的是可见必填完成度

左侧导航或者顶部卡片可以显示“基础信息3/3、教学评价2/4、实践环节1/3、总体反馈0/2”。分母必须来自当前可见且必填的题目,而不是整个题库的题目总数。否则用户选择“未安排实践项目”后,实践环节中的依赖题消失,分组进度仍显示未完成,看起来像怎么填写也不能通过。

第二段代码把可见集合整理成进度记录,并从全部可见题中提取缺答目标。这份计算结果同时提供给进度卡片、提交前校验和缺答跳转;三处不能分别做一遍筛选,避免某一处忘记条件依赖后出现不一致。

export interface GroupProgress {
  groupId: string;
  done: number;
  total: number;
  missingIds: string[];
}

export function collectGroupProgress(
  groupIds: string[], questions: Question[], answers: AnswerMap
): GroupProgress[] {
  const visible = visibleQuestions(questions, answers);
  return groupIds.map((groupId: string) => {
    const required = visible.filter((q: Question) =>
      q.groupId === groupId && q.required);
    const missingIds = required.filter((q: Question) =>
      !isAnswered(q, answers)).map((q: Question) => q.id);
    return { groupId, total: required.length,
      done: required.length - missingIds.length, missingIds };
  });
}

某个分组的可见必填总数为零时,它不应该被显示为“0/0未完成”。界面可以写成“当前无必填”,让用户知道这不是错误状态。对于选填题,回答与否可以独立显示,但不会影响提交门禁。如果在输入过程中调用这些纯函数,建议先更新答案快照,再统一生成下一次的进度数据;如果先算统计再改答案,导航上的数字通常会晚一拍。

开发环境中的分组进度与定位逻辑

用一份扁平索引解决分组与题目跳转

我更倾向于让分组导航和题目列表共用一份扁平显示数据,而不是为每组创建互不相干的页码。显示数据包含分组标题行和题目行,缺答定位时按题目ID取得当前渲染位置;即使题目由于条件规则隐藏,也不会留下一个旧下标。列表顺序确定后,Scroller.scrollToIndex可以将对应行带入视区。

第三段代码生成扁平行数据。为了让代码的含义清楚,标题行和题目行分别有稳定的key;页面仅对当前可见题目建索引,不能把隐藏题保存在列表里靠透明度处理。

export interface SurveyRow {
  key: string;
  kind: 'HEADER' | 'QUESTION';
  groupId: string;
  questionId: string;
  title: string;
}

export function buildSurveyRows(
  groupIds: string[], names: Map<string, string>,
  questions: Question[], answers: AnswerMap
): SurveyRow[] {
  const visible = visibleQuestions(questions, answers);
  const rows: SurveyRow[] = [];
  groupIds.forEach((groupId: string) => {
    rows.push({ key: `group:${groupId}`, kind: 'HEADER', groupId,
      questionId: '', title: names.get(groupId) ?? groupId });
    visible.filter((q: Question) => q.groupId === groupId)
      .forEach((q: Question) => rows.push({ key: `question:${q.id}`,
        kind: 'QUESTION', groupId, questionId: q.id, title: q.title }));
  });
  return rows;
}

export function rowIndexOf(rows: SurveyRow[], id: string): number {
  return rows.findIndex((row: SurveyRow) =>
    row.kind === 'QUESTION' && row.questionId === id);
}

分组标题本身也占列表的一行,索引从零开始,因此跳转时应直接从实际rows中查找,而不是把题目在组内的下标相加。对于长问卷,还可以用ListItemGroup显示带标题的内容,但定位下标需要考虑分组容器;本节先使用普通ListItem统一索引,便于维护。发布问卷新版本后,要重新派生列表和缺答集合,不能用旧草稿保存的像素偏移恢复定位。

把定位结果和输入焦点分开

滚动到某个缺答题,只意味着它进入可见区域,并不等于输入框获得焦点。对单选、评分和文本题应分别处理焦点策略:单选可以突出目标卡片,文本题可以在用户明确点击后获得键盘焦点,评分题则突出待评分星级。不要在自动跳转瞬间同时弹出键盘,避免目标卡片反而被遮挡。

第四段代码在页面中绑定Scroller,根据缺答列表找到第一个未完成ID。这里保证rows已经建立,并且点击定位时可见条件没有改变。条件答案更新时先调用业务层清理隐藏答案,再刷新列表与进度;目标若已隐藏,应当取消高亮而不是继续滚动到原来的位置。

@Entry
@Component
struct SurveyGroupPage {
  @State rows: SurveyRow[] = [];
  @State missing: string[] = [];
  @State focusedQuestionId: string = '';
  private listScroller: Scroller = new Scroller();

  locateFirstMissing(): void {
    if (this.missing.length === 0) return;
    const id = this.missing[0];
    const index = rowIndexOf(this.rows, id);
    if (index < 0) {
      this.focusedQuestionId = '';
      return;
    }
    this.focusedQuestionId = id;
    this.listScroller.scrollToIndex(index, true);
  }

  build() {
    Column({ space: 12 }) {
      Button('查看下一处缺答').onClick(() => this.locateFirstMissing())
      List({ space: 8, scroller: this.listScroller }) {
        ForEach(this.rows, (row: SurveyRow) => {
          ListItem() {
            Text(row.title)
              .fontColor(row.questionId === this.focusedQuestionId ?
                Color.Red : Color.Black)
              .padding(12)
          }
        }, (row: SurveyRow) => row.key)
      }.height('75%')
    }.padding(12)
  }
}

@State里保存的rows与missing应由同一次计算结果替换,避免用户正准备跳转时缺答列表和页面显示不同步。若选项触发题目重新排序,可在新的列表完成布局后再提供定位入口,或者通过稳定ID重新求索引;过早用旧索引滚动会看到错误位置。对于分组特别多的问卷,列表还应考虑懒加载的使用条件和题目组件的状态复用,不能只依赖增加预加载行数解决性能问题。

跨页缺答定位的手机页面

如何观察真正重要的故障线索

调试过程中,我会优先检查四个状态序列。第一类是从“有实践项目”改成“没有”,实践组必填数量是否同步缩小,隐藏问题的答案是否已清理。第二类是从草稿恢复到新问卷版本,是否使用新题库派生分组统计,缺答ID是否仍属于当前可见集合。第三类是点击“下一处缺答”后,目标ID与滚动索引对应的题目是否一致。第四类是用户已经完成一道题,却仍高亮旧缺答位置,说明列表、答案和校验集合的更新次序需要调整。

在HiLog中记录formRevision、groupId、visibleCount、missingCount、targetQuestionId和rowIndex就足以跟踪主要路径。具体回答文本不应该出现在调试日志里,尤其是带有个人信息的自由填写题。对于必填判定,服务层返回题目ID而非把错误提示文本固定写死,这样后续更换问卷文案时也能稳定定位。

整套导航的关键不是按钮多,而是让问卷规则、可见题集合、分组进度和滚动索引都从同一份答案状态推导。这样条件跳题、草稿恢复、题库修订都不会让“还有一道题没答”变成一条找不到目标的提示。下一步再接入提交结果汇总时,也可以直接复用这套分组与缺答索引,不需要增加另一份问卷状态。

分组统计还有一个很实际的展示细节:同一题目可能因为问卷规则升级从“教学评价”调整到“实践环节”。刷新分组后,应按题目的当前groupId重新聚合,而不是保留草稿里旧的分组名。对于长文本答案,页面切换或滚动时要让输入内容回写稳定ID对应的答案快照;复用列表组件不能让上一行的输入状态带到下一行。定位记录可以短暂保存目标题目ID,但不能把列表下标当成需要长期持久化的草稿字段。

此外,用户点击某组的进度卡片时,可以先定位该组第一行;点击“缺答”入口才定位当前最早的缺答题。两种行为共享rowIndexOf,但提示含义不同。为了便于无障碍操作,目标行除颜色高亮外还应给出文字提示,错误位置应能通过朗读或键盘导航识别。这些细节比单纯增加一个红色边框更能减少反复滚动。

相关资料

Logo

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

更多推荐