第15章 - 思维日志-轨迹与家长报告

证据事件是给机器看的:phase: 'observe'、action: 'pattern/inspect'、failureClass: 'pattern-cycle-wrong'。孩子看到的必须是另一种东西:一条能读懂的”先看了哪几格、第三块换成长条、缺口补上了”的回放。把事件翻译成人话的,是 @mmf/evidence 里的 buildTrail() 与 buildReport(),以及外壳的两个页面。

15.1 思维日志:JournalEntry 的生成与保留

日志条目不是从事件流临时拼出来的,而是通关那一刻写进存档的。字段定义在 packages/game-kit/src/save.ts:

export interface JournalEntry {
  id: string;
  at: number;
  levelId: LevelId;
  variantId: string;
  stamps: StampId[];
  toolIds: ToolId[];
  failureClasses: string[];
  revisionCount: number;
  /** 玩家可选的一句话,≤ 80 字;不进任何统计 */
  note?: string;
}

生成发生在 ProgressManager.completeLevel(),和关卡进度、工具使用次数同一次写盘:

// packages/progress/src/manager.ts(节选)
const entry: JournalEntry = {
  id: this.nextJournalId(at),
  at,
  levelId,
  variantId,
  stamps,
  toolIds,
  failureClasses: failureClassesSeen,
  revisionCount,
};
const journal = [...this.current.journal, entry].slice(-MAX_JOURNAL_ENTRIES);

三个常量的取舍:

常量 值 位置 含义
MAX_JOURNAL_ENTRIES 120 packages/progress/src/save.ts 存档里最多留 120 条日志
MAX_NICKNAME_CHARS 12 同上 昵称上限
MAX_JOURNAL_NOTE_CHARS 80 同上 玩家补一句话的上限

调用侧的约定是只在通过时调用 completeLevel()(关卡宿主里由 verdict.passed 分支保证):失败不写日志,但失败类会进证据。因此日志天然是”成功档案”,而”失败→修正”的完整链路要看证据轨迹——两者分工不同,页面也是分栏展示的。

日志页还做了一次截断:存档里按时间升序,页面取最近 120 条并倒序显示。注释说明了这个数字的来源(docs/02 的路由表:最近 20 条 journal + 最近 80 条证据轨迹),实现放宽到 120 条,是因为”完成一关才一条”,120 条已经覆盖很久。

15.2 轨迹:buildTrail 与 TrailItem

轨迹是事件的可读时间线,入口在 packages/evidence/src/trail.ts:

// packages/evidence/src/trail.ts(节选)
/**
 * 思维轨迹:把证据事件按时间排成一条可回看的中文时间线。
 * 纯函数:不读时钟、不碰 DOM;limit 默认 80 条(docs/07 §1.2)。
 * save 仅用于签名对齐:轨迹的唯一事实来源是证据事件。
 */
export function buildTrail(
  _save: SaveData,
  events: EvidenceEvent[],
  summaries: readonly LevelSummary[],
  limit = DEFAULT_LIMIT,
): TrailItem[] {
  const max = normalizeLimit(limit);
  if (max === 0) return [];

  const titles = new Map<string, string>();
  for (const summary of summaries) titles.set(summary.id, summary.title);

  const decorated: { event: EvidenceEvent; at: number; index: number }[] = [];
  events.forEach((event, index) => {
    if (event === null || typeof event !== 'object') return;
    decorated.push({ event, at: normalizeAt(event.at), index });
  });
  decorated.sort((a, b) => a.at - b.at || a.index - b.index);

  const visible = decorated.slice(-max);
  // ……逐条拼成 TrailItem
}

两个防御性细节:_save 参数保留但不用(轨迹的唯一事实来源是事件,签名对齐旧文档);排序时用 at 相同时的原始下标兜底,保证同一毫秒内多条事件顺序稳定。

每条轨迹的结构是 TrailItem:

// packages/evidence/src/types.ts(节选)
export interface TrailItem {
  id: string;
  at: number;
  levelId: LevelId;
  title: string;
  phase: EvidencePhase;
  phaseLabel: string;
  tool?: ToolId;
  toolLabel?: string;
  action: string;
  outcome: 'info' | 'pass' | 'fail';
  failureClass?: string;
  label: string;
}

label 是给渲染层直接用的整句:关卡标题 · 阶段 · 工具:动作(通过 / 未通过:失败类)。八种阶段的中文映射在 trail.ts 里:

const PHASE_LABELS: Record<EvidencePhase, string> = {
  observe: '观察', hypothesize: '猜想', construct: '搭建', run: '运行',
  result: '结果', revise: '修订', generalize: '推广', argue: '论证',
};

action 原样保留(如 pattern/inspect),因为它对成人排查有用;但 label 已经把它拼进句子,孩子读到的是”铺砌挑战 · 观察 · 模式卡:pattern/inspect”——动作名仍是英文 id,这是当前的一处粗糙点,见 15.8。

15.3 失败类中文化:分类名进正文,原始 id 进 title

失败类在运行时是英文 id:pattern-cycle-wrong、schedule-conflict、inference-out-of-range。直接显示会让孩子读到一串英文,所以日志页做了一层映射。它把前缀归到 failcat.* 分类:

// apps/site/src/views/JournalView.vue(节选)
/**
 * 失败类在运行时是英文 id(pattern-cycle-wrong)。直接显示会让孩子读到一串英文,
 * 这里按前缀归到 failcat.* 的中文分类,原始 id 只留在 title 里给大人排查。
 */
const FAIL_CATEGORY_PREFIXES: readonly (readonly [string, string])[] = [
  ['pattern', 'failcat.pattern'],
  ['construct', 'failcat.construct'],
  ['rule', 'failcat.rule'],
  ['counter', 'failcat.counter'],
  ['probe', 'failcat.probe'],
  ['exp', 'failcat.exp'],
  ['sim', 'failcat.sim'],
  ['queue', 'failcat.queue'],
  ['network', 'failcat.network'],
  ['schedule', 'failcat.schedule'],
  ['sampling', 'failcat.sampling'],
  ['inference', 'failcat.inference'],
  ['distribution', 'failcat.distribution'],
  ['frontier', 'failcat.frontier'],
  ['argue', 'failcat.argue'],
];

function failCategoryKey(failureClass: string): string {
  const prefix = failureClass.split('-')[0] ?? '';
  for (const [match, key] of FAIL_CATEGORY_PREFIXES) {
    if (prefix === match) return key;
  }
  return 'failcat.other';
}

分类名本身在 packages/i18n/src/messages.ts:

'failcat.pattern': '规律的规律',
'failcat.construct': '摆放与构造',
'failcat.rule': '程序与路线',
'failcat.queue': '排队指标',
'failcat.other': '结构反馈',

同一分类只显示一次,原始 id 合进 title 属性:

// apps/site/src/views/JournalView.vue(节选)
/** 同一分类只显示一次,原始 id 们合进 title,悬停能看到全部。 */
function failureCategories(ids: readonly string[]): { key: string; raw: string[] }[] {
  const grouped = new Map<string, string[]>();
  for (const id of ids) {
    const key = failCategoryKey(id);
    const list = grouped.get(key);
    if (list) list.push(id);
    else grouped.set(key, [id]);
  }
  return [...grouped.entries()].map(([key, raw]) => ({ key, raw }));
}

渲染时 :title="category.raw.join(', ')",孩子看到”⚠ 规律的规律”,鼠标悬停或读屏才能拿到原始 id。这不是”藏信息”,而是分层:孩子需要的是”哪一类地方卡住了”,成人排查才需要精确的失败类。

轨迹组件 TrailList.vue 也做了同样的替换,但方式不同——它拿到的是已经拼好的整句 label,只能在渲染前做字符串替换:

// apps/site/src/components/TrailList.vue(节选)
function displayLabel(item: TrailItem): string {
  if (!item.failureClass) return item.label;
  return item.label
    .replace('(未通过:' + item.failureClass + ')', '(未通过:' + failureLabel(item.failureClass) + ')')
    .replace(' (failed: ' + item.failureClass + ')', ' (failed: ' + failureLabel(item.failureClass) + ')');
}

注释把原因写得很直白:”buildTrail/localizeTrail 已经把失败类拼进 label 的括号里,宿主之外改不了那两个包,这里在渲染前把原始 id 换成分类名。”这是纯函数层与视图层分工的代价:label 是方便渲染的产物,就不方便再加工。

15.4 日志页与轨迹组件

JournalView.vue 是两栏布局:左边”回放”列表,右边”证据时间线”。

// apps/site/src/views/JournalView.vue(节选)
/** 最近 120 条回放(docs/02 §10.1);再往前的记录留在证据库里,不被日志页淹没。 */
const entries = computed(() => [...(progress.save?.journal ?? [])].reverse().slice(0, 120));
const trail = computed(() => localizeTrail(
  buildTrail(progress.save ?? emptySave(0), progress.evidence, LEVEL_SUMMARIES, 80),
  LEVEL_SUMMARIES,
  locale.value,
));

onMounted(async () => {
  await progress.init();
  await progress.loadEvidence(300);
});

三个要点:

  1. 空档兜底:progress.save ?? emptySave(0),读档失败时页面仍能渲染空状态,而不是白屏;
  2. 两条数据源:日志来自存档 save.journal,轨迹来自证据库 progress.evidence,后者由 loadEvidence(300) 显式加载;
  3. 语言切换:轨迹走 localizeTrail(),日志条目的关卡标题走 localizedSummary(),两者都不在组件里硬编码文案。

TrailList.vue 只负责渲染,输入是 items 与可选 failureLabels:

<!-- apps/site/src/components/TrailList.vue(节选) -->
<ol v-else class="relative flex flex-col gap-3 border-l border-grid pl-5">
  <li v-for="item in items" :key="item.id" :data-testid="'trail-item-' + item.id"
    class="relative min-w-0 rounded-2xl border border-grid bg-card p-4">
    <span class="absolute -left-[27px] top-5 h-2.5 w-2.5 rounded-full border-2 border-paper"
      :class="item.outcome === 'fail' ? 'bg-red' : item.outcome === 'pass' ? 'bg-teal' : 'bg-brass'"
      aria-hidden="true" />
    <p class="text-sm font-bold text-ink"> · </p>
    <p class="mt-1 text-sm leading-6 text-ink-soft"></p>
  </li>
</ol>

时间线的圆点用颜色区分结果(红=失败、青=通过、铜=中性),同时色点带 aria-hidden,语义由文字承担——颜色不是唯一信息通道。

15.5 一页式报告:buildReport 与 ReportModel

报告是 @mmf/evidence 里最大的纯函数,输入是存档、事件、摘要与 now,输出一屏能读完、能打印 A4 的结构:

// packages/evidence/src/report.ts(节选)
export function buildReport(
  save: SaveData,
  events: EvidenceEvent[],
  summaries: readonly LevelSummary[],
  now: number,
): ReportModel {
  const generatedAt = normalizeTime(now);
  const safeEvents = validEvents(events);
  const profile = buildEvidenceProfile(save, safeEvents, summaries, generatedAt);
  // ……工具次数、关卡表、周期、隐私声明
}

ReportModel 的字段与报告页的五个小节一一对应:

// packages/evidence/src/types.ts(节选)
export interface ReportModel {
  generatedAt: number;
  nickname: string;
  periodDays: number;
  tools: { tool: ToolId; label: string; uses: number }[];
  levels: ReportLevelRow[];
  dimensions: DimensionView[];
  recommendations: Recommendation[];
  totalJournal: number;
  totalEvents: number;
  privacyNote: string;
}

几个口径值得记住:

  • 周期:从最早一条记录(档案创建时间、证据、日志三者取最小)到 now,向上取整,按 7 天封顶。所以报告写的”最近 N 天”,N 最大是 7,不会因为用了三个月就写成 90 天。
  • 关卡表:按 summaries 顺序输出全部已发布关卡,未玩过的也在表里(completed: false),每行带印章、变体数与最近游玩时间。ReportLevelRow 的定义在 types.ts。
  • 工具次数:来自 toolUseCounts(),事件优先、存档兜底;某件工具在事件里没记录时才回落到 save.tools[tool].uses。
  • 隐私声明:常量 PRIVACY_NOTE = '数据只在本机,不含正确率、刷题量或排名。',报告页底部原样展示。

报告页的渲染顺序固定在 ReportView.vue:一、用了哪些工具;二、四维思维证据;三、关卡与印章;四、下一步建议;页脚是隐私声明与”本周日志 / 证据事件”计数。四维卡片里除了 summary 还列出信号明细(信号名:值 / 样本量),家长可以核对”这个档位是怎么来的”。

报告页的解锁逻辑单独一节讲,因为它是报告唯一的入口。

15.6 打印:@media print 只做减法

报告页的”打印 / PDF”按钮只有一行:

// apps/site/src/views/ReportView.vue(节选)
function printReport(): void {
  window.print();
}

真正的工作在 apps/site/src/style.css 的打印媒体查询里:

@media print {
  /* 只藏外壳导航;报告页自己的 <header>(标题/署名/生成时间)要留在纸上 */
  header.app-header,
  nav,
  .no-print {
    display: none !important;
  }

  html, body, #app {
    height: auto;
    overflow: visible;
    background: #ffffff;
  }

  main { overflow: visible !important; }

  .report-page {
    box-shadow: none !important;
    border: none !important;
    max-width: 100% !important;
    padding: 0 !important;
  }
}

四个决定:

  1. 只藏 header.app-header,不藏所有 header。这里踩过坑:早期写成 header,把报告页自己的标题栏也藏掉了,打印出来第一页顶部是空的(见 docs/10 的审查记录)。
  2. .no-print 藏按钮组。打印/导出/导入三个按钮在 div.no-print 里,纸上不需要它们。
  3. 展开滚动容器。屏幕上是 main { overflow-y: auto },打印时必须改成 visible,否则只打印第一屏。
  4. 报告页去装饰。圆角卡片在纸上没有意义,去掉阴影、边框、内边距,进度环与彩色贴纸保留(浏览器支持彩色打印)。

打印目标的 A4 尺寸由浏览器的”另存为 PDF”处理,项目不自己生成 PDF——没有依赖、没有字体嵌入问题,也符合”零联网”。

15.7 成人门:长按 3 秒,键盘也能用

报告入口不在孩子的主路径上,放在收藏柜里,并且需要长按 3 秒:

// apps/site/src/components/AdultGate.vue(节选)
const holding = ref(false);
let timer: number | null = null;

function start(): void {
  if (holding.value) return;
  holding.value = true;
  clearTimer();
  timer = window.setTimeout(() => {
    timer = null;
    holding.value = false;
    emit('unlock');
  }, 3000);
}

function cancel(): void {
  clearTimer();
  holding.value = false;
}

onBeforeUnmount(clearTimer);

模板上的事件绑定决定了”长按”在三种输入设备上都成立:

<!-- apps/site/src/components/AdultGate.vue(节选) -->
<button
  type="button"
  data-testid="adult-hold"
  class="min-h-14 w-full max-w-sm rounded-2xl px-5 text-base font-bold text-white active:scale-95"
  :class="holding ? 'bg-brass' : 'bg-blueprint'"
  @pointerdown.prevent="start"
  @pointerup="cancel"
  @pointerleave="cancel"
  @pointercancel="cancel"
  @keydown.space.prevent="start"
  @keyup.space="cancel"
  @keydown.enter.prevent="start"
  @keyup.enter="cancel"
>
  
</button>
  • 鼠标/触屏走 pointerdown / pointerup / pointerleave / pointercancel;指针移出按钮会取消,不会”按到一半滑走也算”;
  • 键盘走 keydown / keyup 的 Space 与 Enter —— 这是可访问性要求,也是 e2e 的稳定入口:e2e/narrative.spec.ts 用 hold.focus() 加 page.keyboard.down('Space') 解锁,省掉了 3.3 秒硬等待;
  • onBeforeUnmount(clearTimer) 防泄漏:解锁前离开页面,定时器会被清掉。

解锁后 ReportView.vue 才取数并渲染:

// apps/site/src/views/ReportView.vue(节选)
async function unlock(): Promise<void> {
  await progress.init();
  await progress.loadEvidence(500);
  await progress.refreshBadges();
  if (!progress.save) return;
  report.value = localizeReport(
    buildReport(progress.save, progress.evidence, LEVEL_SUMMARIES, platform.now()),
    LEVEL_SUMMARIES,
    locale.value,
  );
  unlocked.value = true;
}

注意顺序:先读档、再加载证据(500 条,比日志页的 300 多)、再重算徽章,最后才生成报告。任何一步失败都不会把 unlocked 置真,家长看到的是入口而不是半截报告。

15.8 报告本地化与已知缺口

英文界面下,报告正文由 localizeReport() 处理:工具名、关卡名、四维标签与档位描述、推荐句、隐私声明都会换成英文,昵称”小工匠”显示为 “Little Maker”。但报告页仍有硬编码中文:五处小节标题(”一、用了哪些工具”等)与按钮文案(”打印 / PDF”“导出证据包”“导入只读证据包”)都写在模板里,不走 t()。这是当前的真实状态,写英文内容前应先补文案键。

其他已知缺口一并列清楚:

  • action 仍是英文 id。轨迹句子里嵌着 pattern/inspect 这类动作名,孩子读到的是”铺砌挑战 · 观察 · 模式卡:pattern/inspect”。要彻底中文化需要给 action 建一张文案表,涉及六个模板的 action 命名,属独立改动。
  • 玩家补一句话的入口没有接上。JournalEntry.note 字段、清洗逻辑(截到 80 字)都已存在,但视图层没有输入框;首次导入的 note 会被完整保留。
  • 日志条数上限是 120,但页面标题写”最近 N 条记录”。N 是页面上实际渲染的条数,不是总数;总数没有展示,避免家长把”记录多”误读成”学得多”。
  • 报告页只读导入不做合并。导入的 ReportPack 只渲染成一张只读卡片,不写 SaveData;教师看学生档案只能”导入后看”,不能”导入后继续玩”。
  • 打印样式没有做 A4 像素级回归。@media print 是人工走查项,没有截图基线;分页位置随内容长度浮动,长报告可能把”关卡表”切成两页。发布检查清单里对应一条”打印报告 A4 正常”,目前靠人工勾选。

小结

日志、轨迹、报告是同一份证据的三种读法:

  • 日志是通关时写进存档的摘要(JournalEntry,最多 120 条),只记录”通过了什么、用了什么、失败过哪些类、修订几次”;
  • 轨迹是事件流的时间线(buildTrail(),默认 80 条),能读到观察与运行,不依赖玩家是否通关;
  • 报告是一页式汇总(buildReport()),四维用档位句而不是分数,周期按 7 天封顶,隐私声明固定;
  • 失败类在三个视图里都做分层:分类名给孩子,原始 id 留 title 给成人;
  • 打印只做减法(藏导航、去卡片、展开滚动),不引 PDF 库;
  • 成人门用”长按 3 秒 + 键盘 Space/Enter”实现,既有门槛也可自动化。

下一章回到数据底座:存档怎么迁移、证据怎么裁剪、报告包怎么导出导入,以及清档为什么只有 API 没有入口。