Appearance
所见即所得
背景:模型行点击行为不一致、收藏星标和相机图标不实时更新。
过程:数据与视图同步机制统一 + 新增/聚焦按钮拆分 + getLevel/setLevel API。
桌面壳最近在烦恼一件事。
"我骗用户。"它说。
MenuStack 正在清点自己的层级数,听到这话停下手:"什么东西?"
"收藏。用户点了一下那个 ☆,后端 ToggleFavorite 确实把 libraryRef 写进了 config.json。但前端——我——什么反应都没有。星标不变。计数不变。就好像什么都没发生过。"
MenuStack 想了想:"那就是什么都没发生过。在用户眼里。"
桌面壳翻出自己身上那套"旧"逻辑,追溯到 buildModelDetailLevel。
这个函数是模型详情页的构造函数。每次调用它,它会问 favorites.has(libRef)——此刻模型收藏了没有?然后根据答案,返回一行 "☆ 收藏" 或 "★ 取消收藏"。
"这很对。"桌面壳盯着代码说。
"错就错在这里。"它自己反驳自己。
它顺着调用链往下走:用户点击 ☆ → onItemClick → ToggleFavorite → 数据变了 → modelStack.reRender()。
reRender() 是议会引以为傲的全量刷新机制——清除所有浮层,用存储的 PopupLevel 数组重建 DOM。
"问题就在这里。"桌面壳把光标戳在 reRender 那行代码上。
"我重建 DOM,用的却是旧数据。"
buildModelDetailLevel 只在用户进入详情页时调用一次。返回的 PopupLevel 里,items 数组写死了 label: "☆ 收藏"。reRender 照着这张旧地图重建界面——地图标着"☆",领土已经变成"★"了。
"议会,你的 reRender 是重新画墙,不是重新测量。"桌面壳说。
MenuStack 沉默了很久。
"你说得对,"它终于承认,"我的 levels 数组只存结果,不存公式。保存的时候是什么数据,刷新的时候就是什么数据。它不会自己再算一遍。"
"那你怎么不存公式?"桌面壳问。
"因为性能。每层都存一个工厂函数,每次 reRender 都重新执行一遍——深度导航的时候卡死你。"
"那就给我一个改数据的入口。"
"什么入口?"
"让我能替换某一层的数据。不是全量重建,是精准换掉某一层的地图。"
MenuStack 在它的宪法里加了三行 API:
getLevel(index) → PopupLevel | undefined
setLevel(index, level) → void
levelCount → number"第一行让外界读我的层。第二行让外界换我的层。第三行让外界知道我有多深。"
"我不喜欢暴露内部。"它说。
"我也不喜欢数据对不上。"桌面壳说。
它们对视了三秒钟。这是议会第一次承认:为了准确,可以牺牲封装。
桌面壳拿着新 API 回去修 detail:fav 的 bug。
以前:
typescript
modelStack?.reRender();现在:
typescript
if (modelStack) {
// 更新根层的收藏计数
const root = modelStack.getLevel(0);
if (root) {
const favFolder = root.items.find(i => i.target === "__favorites__");
if (favFolder) {
favFolder.sublabel = favorites.size > 0
? `${favorites.size} 个模型`
: "暂无收藏";
}
}
// 替换详情层为全新数据
const detailIdx = modelStack.levelCount - 1;
modelStack.setLevel(detailIdx, buildModelDetailLevel(id));
modelStack.reRender();
}"你在改议会的数据?"StartFileServer 从端口二十三探头。
"我在让地图追上领土。"桌面壳说。
但它没有停。
它想起刚才跟 MenuStack 的对话里提过另一个症状——相机模式切换后,✓ 不挪窝。
它打开 scene-menu.ts,看到 buildCameraLevel 在做一模一样的事:
typescript
const currentMode = getCameraMode();
return {
label: "相机模式",
items: [
{ label: "轨道", icon: currentMode === "orbit" ? "check" : "circle" },
{ label: "自由飞行", icon: currentMode === "freefly" ? "check" : "circle" },
...
],
};"一模一样。算一次,用永远。议会那套 reRender 照着旧快照画界面,✓ 当然不动。"
它用同样的 API 修了:
typescript
if (sceneStack) {
sceneStack.setLevel(sceneStack.levelCount - 1, buildCameraLevel());
sceneStack.reRender();
}这次 ✓ 动了。
"两个 bug,一个根因。"桌面壳对 MenuStack 说。
"我身上的所有 renderCustom 和 items 数组——只要里面出现了条件判断——都是在创建时快照的。"
"什么意思?"
"意思是你写了 currentMode === "orbit" ? "check" : "circle" 或者 favorites.has(libRef) ? "★" : "☆",这个三元表达式只在 buildCameraLevel 或 buildModelDetailLevel 被 onFolderEnter 调用时执行一次。之后不管怎么 reRender,它都不重算了。"
"所以条件渲染在议会里是单次的。"
"对。"桌面壳说。"要么你不要状态依赖,要么你每次状态变了手动替换层。"
"我没有第三个选项。"
"没有。"
但修完状态时隐时现的 bug 后,桌面壳发现了一个更深的问题。
它翻出三天前自己改的另一段代码——模型行的点击行为。
以前点击行是"新增一个模型到场景"。后来改成了"替换当前焦点模型"。还在行右边加了个「+」按钮,点它才是"新增"。
"这其实跟收藏/相机是同一个道理。"桌面壳自言自语。
"什么道理?"
"用户看到一个按钮,以为它做 A,它做 B。收藏的 ☆ 点了没反应,是用户看不到状态的变化。📄 按钮点了进详情——用户以为它只是聚焦模型。"
它打开 menu.ts,看着「📄」按钮的点击事件:
typescript
detailBtn.addEventListener("click", (e) => {
e.stopPropagation();
const next = this.onFolderEnter?.(row, this);
if (next) this.push(next);
});"这跟用户直接点行有什么区别?行也是 onFolderEnter,📄 也是 onFolderEnter。两个按钮做同一件事。"
"那用户为什么要点 📄?"
"因为他们以为 📄 是别的功能。"桌面壳说,"他们以为 📄 是'聚焦这个模型'。不是'进入详情页'。"
桌面壳在 PopupRow 上加了一个可选字段:onDetailClick。
"如果行设置了 onDetailClick,📄 按钮就执行它,不执行 onFolderEnter。如果没有设置——回退到老行为。"
在 showPopup 里,所有已加载模型行的 📄 按钮变成了:
typescript
onDetailClick: () => {
setFocusedModelId(id);
focusModel(id);
setStatus(`🎯 聚焦: ${inst.name}`, true);
};"这才对。"桌面壳说。"📄 是聚焦,行是详情,+ 是新增。三个按钮,三个行为,互不打架。"
那天晚上,桌面壳坐回终端前,看着今天改完的所有代码。
17 个文件修改。3 个 TypeScript 编译全部通过。0 个运行时警告。
它改了:
- 模型行的点击行为——「替换」取代「新增」,因为用户习惯把点击理解成"换一个"
- 新增「+」按钮——给需要"再多一个"的用户留了出口
- 「📄」按钮——从"进详情"改成"聚焦模型",因为用户想快速在场景里跳转
- 收藏的 ☆/★ 状态——现在点了立刻变
- 相机模式的 ✓ 图标——现在切换了立刻动
前三条是关于行为的语义——让每个操作对应一个明确的预期。 后两条是关于状态的可见性——让每个变化呈现在界面上。
"联邦'行为地图'的更新频率比我想象的高。"它说。
"你又画了新地图。"MenuStack 说。
"是更正。旧地图标的'这里有一座山',实际是一片湖。我只不过把山的图标换成湖了。"
联邦地图上多了一行注脚。
不是新的城邦入伙。不是新的能力收编。不是新的边界。
是旧地图的修正声明:
以下地标与实际地理不符:
- 📄 按钮标注为"详情入口",实际为"聚焦入口"
- 收藏星标标注为"静态装饰",实际为"可交互状态"
- 相机模式切换标注为"瞬间生效",实际为"状态残留"
"这不算收编。"桌面壳说。
"那算什么?"
"算地图质量委员会的第一条决议。不修边界,修标注。不添能力,修误解。"
MenuStack 沉默了一拍。
"等你成立第二条决议的时候,叫我。"
教训:地图不是领土,但必须追上领土。