实验目录页、实验模拟页和场景选择页单独看都不复杂目录页展示卡片模拟页根据expId创建引擎场景页让用户在标准、加热、低温、高压和自定义环境中选择一个选项。真正容易出问题的是三页之间的契约。在化学实验室 1.0.5的实际 ArkTS 源码中Experiment.ets已经提供 13 个实验Scene.ets提供 5 个环境LabPage.ets也能把实验 ID 传给模拟页。但继续检查SceneSelectorPage.ets会发现三个断点默认值basic_lab不存在于场景数据中“确认选择”按钮没有点击处理模拟页的路由参数也没有sceneId。结果就是场景卡片可以高亮选择却不会影响任何一次实验。本文唯一复核标记CHEM-LAB-ONE12-SCENE-CONTRACT-20260729这类问题不能靠给按钮补一行跳转彻底解决。更稳的目标是让场景选择不是一个孤立页面而是实验运行上下文的一部分目录负责选择实验模拟页拥有本次运行场景页只编辑环境返回后仍能确认这是同一个实验、同一个场景和同一组环境参数。本文会完成以下工程拆解核对 13 个实验与 5 个场景的真实数据源避免 UI 写出第二份目录。设计默认场景 ID、确认回传、自定义环境和系统返回四类状态断点的修复方案。用强类型ExperimentRunContext连接目录、模拟页、场景页与引擎。给实验卡片、分类筛选、收藏入口和场景返回建立同一套参数校验。提供可在真机或模拟器上执行的路径矩阵与排障表不把未执行的测试写成已通过结果。一、先把真实页面链路画清楚当前工程中与实验入口有关的文件并不只是一张列表页。责任真实文件当前输入当前输出实验目录model/Experiment.ets无13 个Experiment分类与入口pages/LabPage.ets分类索引、收藏 IDexpId、expName实验运行ExperimentSimPage.etsexpId、expName结果载荷场景目录model/Scene.ets无5 个Scene场景选择SceneSelectorPage.ets当前没有路由参数当前没有确认结果收藏入口FavoritesPage.ets收藏项 ID补齐后的实验路由参数页面注册main_pages.json页面路径可路由页面集合从目录进入实验的代码已经具备明确参数router.pushUrl({ url: views/experiment/ExperimentSimPage, params: { expId: exp.id, expName: exp.name } })模拟页也会在aboutToAppear()中读取这两个字段再调用initEngine()。这条主链是可工作的。场景选择应该挂在这条链路上而不是另起一套互不认识的状态。二、实验目录必须是唯一事实源getAllExperiments()已经为每个实验定义了 ID、名称、类别、难度、图标和参数。目录页、收藏页、搜索页或推荐区都应该查询这份数据不要各自维护名称到路由的映射。export interface Experiment { id: string name: string description: string category: string level: string icon: Resource isFavorite: boolean params: ExperimentParam[] } export function getExperimentById(id: string): Experiment | undefined { return getAllExperiments().find((item: Experiment) item.id id) }这层模型有两个直接价值。第一13 个实验的入口身份都由id决定不依赖页面标题第二模拟页收到未知 ID 时可以在进入引擎分支前完成校验而不是静默落到酸碱中和实验。建议再补一个只读目录门面让调用方不必每次重新扫描数组export class ExperimentCatalog { private static readonly items: Experiment[] getAllExperiments() static all(): Experiment[] { return this.items.slice() } static find(expId: string): Experiment | undefined { return this.items.find((item: Experiment) item.id expId) } static contains(expId: string): boolean { return this.find(expId) ! undefined } }slice()防止页面直接修改目录数组contains()则把路由参数校验放回目录层。它不是为了增加抽象数量而是为了阻止 13 份入口规则在不同页面逐渐分叉。三、先修复默认场景 ID 的确定性错误SceneSelectorPage当前的默认值是State selectedId: string basic_lab但getDefaultScenes()中真实存在的 ID 是standard_lab heating_lab low_temp_lab high_pressure_lab custom因此首次打开页面时没有任何卡片满足this.selectedId scene.id。用户会看到“可选择”的列表却看不到默认选择确认逻辑以后即使补上也可能回传一个不存在的 ID。最小修复不是把两个字符串碰巧改成一样而是从数据源产生默认值const DEFAULT_SCENE_ID: string standard_lab export function getDefaultScene(): Scene { const scenes getDefaultScenes() const standard scenes.find((scene: Scene) scene.id DEFAULT_SCENE_ID) return standard ?? scenes[0] }页面初始化时引用同一常量State scenes: Scene[] getDefaultScenes() State selectedId: string getDefaultScene().id这样即使以后调整场景顺序默认状态仍由模型层明确表达。启动检查也可以断言默认 ID 必须存在避免问题等到用户点击时才暴露。四、把一次实验运行定义成强类型上下文仅传expId可以启动引擎却无法描述用户选择的环境。一个稳定的运行上下文至少要包含实验身份、场景身份和环境快照。export interface EnvironmentSnapshot { temperature: number pressure: number } export interface ExperimentRunContext { runId: string expId: string sceneId: string environment: EnvironmentSnapshot updatedAt: number }这里保存的是数值快照而不是只保存sceneId。如果后续版本把“加热实验室”从 100℃ 调整为 120℃已经开始的实验不应该在返回页面后悄悄改变。runId则用于区分同一个实验的两次运行防止旧的场景选择覆盖新会话。上下文创建函数负责做第一层校验export function createRunContext(expId: string, scene: Scene): ExperimentRunContext { const now Date.now() return { runId: ${expId}_${now}, expId, sceneId: scene.id, environment: { temperature: scene.temperature, pressure: scene.pressure }, updatedAt: now } }页面不再临时拼装温度和气压后续的报告、记录和分享也能引用同一份运行事实。五、路由参数进入页面前要先归一化模拟页目前直接把router.getParams()断言成接口类型。类型断言只影响编译器不会让运行时输入自动变得可信。收藏入口、历史记录、深链或旧版本页面都可能只带部分字段。interface SimRouterParams { expId?: string expName?: string runId?: string } interface NormalizedSimParams { expId: string expName: string runId: string } function normalizeSimParams(value: SimRouterParams | undefined): NormalizedSimParams { const requestedId value?.expId ?? const experiment ExperimentCatalog.find(requestedId) ?? ExperimentCatalog.find(acid_base)! return { expId: experiment.id, expName: experiment.name, runId: value?.runId ?? ${experiment.id}_${Date.now()} } }归一化后expId与expName来自同一个Experiment不会出现 ID 指向电解水、标题却显示酸碱中和的错配。默认回退也只发生一次后续引擎、收藏状态和记录都使用归一化结果。六、场景页只编辑环境不拥有实验生命周期场景选择页面不应该启动引擎、保存实验记录或修改学习时长。它的责任只有三件事展示可选场景、编辑自定义环境、把确认结果交回本次运行。可以用一个会话级存储连接两个页面export class RunContextStore { private static contexts: Mapstring, ExperimentRunContext new Map() static put(context: ExperimentRunContext): void { this.contexts.set(context.runId, context) } static get(runId: string): ExperimentRunContext | undefined { return this.contexts.get(runId) } static remove(runId: string): void { this.contexts.delete(runId) } }这份存储只覆盖当前进程的页面会话不写入 Preferences。场景选择是一次运行参数不等同于长期用户设置。如果产品确实要记住“上次使用的场景”应另建偏好字段并明确它只作为下次运行的默认值。七、从模拟页打开场景选择器模拟页拥有runId打开场景页时只需要传递会话身份和当前场景。页面注册已经包含views/experiment/SceneSelectorPage因此重点是参数完整性。private openSceneSelector(): void { const context RunContextStore.get(this.runId) if (!context) return router.pushUrl({ url: views/experiment/SceneSelectorPage, params: { runId: context.runId, expId: context.expId, selectedSceneId: context.sceneId } }) }场景页收到参数后先检查runId对应的上下文是否存在再检查selectedSceneId是否在场景目录中。任何一个条件不满足都回退到standard_lab但不要生成第二个实验会话。八、确认按钮要完成“校验、写回、返回”闭环当前Button(确认选择)没有.onClick()这是选择无法生效的直接原因。完整处理需要同时防止无效 ID 和重复点击。State isSaving: boolean false private confirmSelection(): void { if (this.isSaving) return const scene this.scenes.find((item: Scene) item.id this.selectedId) const current RunContextStore.get(this.runId) if (!scene || !current) return this.isSaving true RunContextStore.put({ ...current, sceneId: scene.id, environment: { temperature: scene.temperature, pressure: scene.pressure }, updatedAt: Date.now() }) router.back() }按钮状态与处理状态绑定Button(this.isSaving ? 正在应用 : 确认选择) .enabled(!this.isSaving) .onClick(() this.confirmSelection())写回成功后使用系统返回而不是再pushUrl一个新的模拟页。否则页面栈会变成“模拟页 A → 场景页 → 模拟页 B”用户按返回键又会回到旧运行参数看起来像被撤销。九、取消与系统返回不能污染原值用户点击顶部返回或系统返回时应保留进入场景页之前的环境。实现方式是把页面里的编辑值当作草稿只在确认时写回RunContextStore。State selectedId: string DEFAULT_SCENE_ID State customTemperature: number 25 State customPressure: number 101.325 private cancel(): void { router.back() }不要在卡片.onClick()中直接修改共享上下文。卡片点击只改selectedId这样“试着点了高压场景又返回”不会影响正在暂停的实验。十、自定义环境必须有可编辑边界现有页面对自定义场景只显示了一个齿轮符号但没有温度和气压控件。要让这个入口可用至少要给数值范围、步长和错误反馈。const MIN_TEMPERATURE: number -50 const MAX_TEMPERATURE: number 500 const MIN_PRESSURE: number 20 const MAX_PRESSURE: number 1000 private validCustomEnvironment(): boolean { return Number.isFinite(this.customTemperature) Number.isFinite(this.customPressure) this.customTemperature MIN_TEMPERATURE this.customTemperature MAX_TEMPERATURE this.customPressure MIN_PRESSURE this.customPressure MAX_PRESSURE }UI 可以在选中custom后展开两个Slider或数字输入组件。确认前再做一次有限数和范围校验避免NaN或极端值进入引擎。边界值应来自业务模型不要散落在组件链中。字段建议范围无效时处理温度-50℃ 500℃禁用确认并显示范围提示气压20kPa 1000kPa保留输入不写回上下文场景 ID场景目录内回退标准实验室会话 ID当前存储中存在返回模拟页并保留原环境十一、返回模拟页后在 onPageShow 恢复环境aboutToAppear()适合创建初始运行从场景页返回时页面实例可能只触发可见性回调。模拟页应在onPageShow()中读取最新上下文并在环境变化时重置实验。onPageShow(): void { const context RunContextStore.get(this.runId) if (!context) return const changed this.sceneId ! context.sceneId || this.temperature ! context.environment.temperature || this.pressure ! context.environment.pressure this.sceneId context.sceneId this.temperature context.environment.temperature this.pressure context.environment.pressure if (changed !this.isRunning) { this.resetExperiment() } }运行中是否允许切换场景需要产品明确决定。对实验模拟应用更可预测的规则是运行中禁用场景入口暂停或完成后允许更换更换后重置进度和结果避免一条曲线混入两套环境。十二、环境参数要通过适配层进入 13 个引擎当前buildEngineParams()根据expId写入反应物参数。温度和气压可以作为公共参数先写入再由具体引擎选择是否使用。private buildEngineParams(params: Mapstring, number): void { params.set(environment.temperature, this.temperature) params.set(environment.pressure, this.pressure) switch (this.expId) { case gas_generation: params.set(cacMass, this.paramValues[0] ?? 5) params.set(acidConc, this.paramValues[1] ?? 1) break case oxygen_prep: params.set(kmnMass, this.paramValues[0] ?? 5) params.set(heatTemp, this.paramValues[1] ?? 300) break default: this.appendExperimentSpecificParams(params) } }不要为了“接入场景”而让所有引擎立刻改变计算公式。第一阶段先完成参数可达性和记录可追溯性第二阶段再为确实受环境影响的气体、加热、干冰等实验增加修正模型。无关实验可以保留环境字段但不参与计算并在报告中说明。十三、分类筛选只筛实验不重建入口规则LabPage.filteredExperiments()当前根据Constants.CATEGORIES过滤category思路是正确的。需要保证分类索引越界时仍能回到“全部”。private filteredExperiments(): Experiment[] { const category Constants.CATEGORIES[this.selectedCategory] ?? Constants.CATEGORY_ALL if (category Constants.CATEGORY_ALL) { return this.experiments } return this.experiments.filter((item: Experiment) item.category category) }筛选结果中的每张卡片仍调用同一个openExperiment(exp)private openExperiment(exp: Experiment): void { const defaultScene getDefaultScene() const context createRunContext(exp.id, defaultScene) RunContextStore.put(context) router.pushUrl({ url: views/experiment/ExperimentSimPage, params: { expId: exp.id, expName: exp.name, runId: context.runId } }) }这保证“全部”“初中化学”“高中化学”“趣味实验”看到的只是不同集合而不是不同跳转实现。十四、收藏入口也必须进入同一条运行链FavoritesPage.openFavorite()已经根据收藏项 ID 查询实验并补齐expId、expName。这是正确方向。下一步不是推翻它而是让它调用与LabPage相同的运行创建函数。private openFavorite(item: FavoriteDisplayItem): void { if (item.type ! experiment) { this.openNonExperimentFavorite(item) return } const experiment ExperimentCatalog.find(item.id) if (!experiment) return ExperimentNavigator.open(experiment) }ExperimentNavigator.open()内部创建默认场景上下文并执行跳转。这样从目录、收藏、历史记录或搜索打开同一个实验时默认场景、参数校验和运行 ID 规则完全一致。十五、页面 UI 状态要与业务状态一致场景选择器还需要补齐几个用户能感知的状态状态页面表现可执行动作正常一项明确选中切换、确认、返回自定义编辑展开温度和气压校验后确认参数无效字段提示错误允许修改禁用确认会话丢失显示“本次实验已结束”返回实验目录正在保存按钮显示处理中禁止重复确认小窗口列表内部滚动底部按钮始终可达现有根节点已经使用statusBarHeight和bottomBarHeight。底部确认区还应把系统避让区和固定间距叠加考虑尤其是在手势导航、横屏和小窗下不能只依赖.margin({ bottom: 24 })。十六、验证矩阵13 个实验不必手工重复 13 套代码入口一致性适合做数据驱动检查。先验证目录中的每个 ID 都能创建引擎再抽取受环境影响最明显的实验做场景组合测试。const experiments ExperimentCatalog.all() for (const experiment of experiments) { const scene getDefaultScene() const context createRunContext(experiment.id, scene) const normalized normalizeSimParams({ expId: experiment.id, expName: experiment.name, runId: context.runId }) console.info(${normalized.expId}:${normalized.runId}) }建议的人工验证表如下路径操作预期结果目录 → 模拟页打开 13 个实验标题、引擎、参数定义与 ID 一致模拟页 → 场景页 → 确认选择高压环境返回原模拟页显示 500kPa模拟页 → 场景页 → 返回选择后不确认原场景不变自定义场景输入边界内数值保存快照并重置未运行实验自定义场景输入越界数值明确提示不能确认收藏 → 模拟页打开任意实验收藏不回退到酸碱中和分类切换多次切换后打开卡片仍使用统一导航函数系统返回连续返回页面栈不出现重复模拟页十七、常见故障与排查顺序现象优先检查典型原因修复方向首次没有选中项selectedId与场景目录默认 ID 不存在从getDefaultScene()初始化点确认无反应按钮事件与会话没有.onClick()或会话丢失校验后写回并router.back()返回后还是旧场景onPageShow()只在aboutToAppear()读取可见时恢复上下文场景改变但曲线不变引擎参数 Map环境没有进入适配层先传公共参数再按实验消费收藏总进默认实验收藏跳转参数只保存 route没有 ID查询目录后补齐expId返回键出现两个模拟页页面栈确认时再次pushUrl写回后返回原页面自定义数值导致异常输入校验NaN、无限值或越界有限数与范围双重检查排查时先确认运行上下文中的runId、expId、sceneId是否一致再看 UI 高亮最后才进入具体引擎。这样能快速区分“选错实验”“回传失败”和“公式暂未消费环境参数”三种问题。十八、建议的落地顺序一次性重写 13 个引擎风险很高可以按下面的顺序收敛修正默认场景 ID并给确认按钮补齐回传闭环。新建ExperimentRunContext与RunContextStore让模拟页拥有会话。把目录、收藏和其他入口统一到ExperimentNavigator.open()。在onPageShow()恢复场景确认后重置未运行实验。给自定义环境增加范围校验和错误状态。把温度、气压作为公共参数传入引擎适配层。先为气体生成、氧气制取、干冰实验验证环境影响再扩展其他实验。通过路径矩阵检查确认、取消、系统返回、分类和收藏入口。每一步都能独立验证也不会要求所有引擎在同一次提交里改变行为。十九、源码事实与建议实现的边界场景选择器最容易产生的误解是把 UI 上“能选择”当成业务上“已经生效”。下面按真实源码和建议方案分开归档所有当前事实都可以从文首列出的文件直接复查。类别当前源码可直接确认本文建议但未声称已合入实验目录getAllExperiments()返回 13 个离线实验通过统一 Navigator 收口所有实验入口场景目录getDefaultScenes()返回 5 个场景为自定义场景增加输入表单、范围校验和草稿状态默认选择selectedId初始化为不存在的basic_lab改成standard_lab或从合法运行上下文恢复卡片交互点击卡片只更新selectedId和高亮样式将选择写入独立草稿取消时不污染原值确认按钮当前按钮没有onClick校验后写回运行上下文并router.back()模拟页参数当前只读取expId、expName增加runId、sceneId或窄化的场景快照收藏入口已根据收藏 ID 查询目录并补齐实验名称与普通目录入口复用同一参数规范化函数场景回传当前源码没有从场景页向模拟页传递选择结果使用页面栈恢复或窄化状态存储完成回传引擎影响当前场景温度、气压未进入 13 个实验引擎通过适配层决定不同实验如何消费环境参数路由注册main_pages.json已注册SceneSelectorPage注册并不等于页面已接入完整业务入口本文没有声称这些建议已经通过工程构建、模拟器或真机验证。实施时应按第十八节逐步修改并分别检查确认、取消、系统返回、收藏入口以及 13 个实验的兼容性。总结一个可靠的场景选择器不是“列表中多一个勾选状态”而是一条完整的运行契约实验目录提供唯一expId运行上下文绑定runId与环境快照场景页只编辑草稿并在确认时写回模拟页在恢复可见时读取新环境引擎适配层决定哪些公式真正消费温度和气压。把这条链路写清以后13 个离线实验就不再依赖 13 套跳转细节。目录筛选、收藏入口、场景确认和系统返回都能回到同一个可预测会话后续增加实验或环境时也只需要扩展目录与适配器而不必重新修补页面之间的隐式约定。AI 辅助声明本文由 AI 辅助完成源码梳理、结构校对与配图设计技术结论均以文中列出的本地 ArkTS 源码为依据并由作者复核后发布。