第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);
});
三个要点:
- 空档兜底:
progress.save ?? emptySave(0),读档失败时页面仍能渲染空状态,而不是白屏; - 两条数据源:日志来自存档
save.journal,轨迹来自证据库progress.evidence,后者由loadEvidence(300)显式加载; - 语言切换:轨迹走
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;
}
}
四个决定:
- 只藏
header.app-header,不藏所有header。这里踩过坑:早期写成header,把报告页自己的标题栏也藏掉了,打印出来第一页顶部是空的(见docs/10的审查记录)。 .no-print藏按钮组。打印/导出/导入三个按钮在div.no-print里,纸上不需要它们。- 展开滚动容器。屏幕上是
main { overflow-y: auto },打印时必须改成visible,否则只打印第一屏。 - 报告页去装饰。圆角卡片在纸上没有意义,去掉阴影、边框、内边距,进度环与彩色贴纸保留(浏览器支持彩色打印)。
打印目标的 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 没有入口。