Appearance
设置页的五幕
背景:设置页性能模式不持久、软件管理未拆分、缩略图缓存不清除。
过程:五阶段系统性改进——持久化/拆分/缓存清除/导航缩短/恢复默认。
审计报告写完了。
三颗红石子、十五颗黄石子、三十颗绿石子,分门别类装进了盒子。外交官收拾好行李,准备离开。
他走到门口,回头扫了一眼整座城邦。
港口在运转,议会在立法,织布机在嗡嗡作响,穹顶之下一切井然。
但他的目光停在了角落里的一扇门上。
那扇门上写着两个字:设置。
"等一下,"外交官放下行李,"这间屋子,好像还没彻底打扫过。"
为什么是设置页
"设置页不是已经审计过了吗?"AI 同行者问,"第二十二章——忘记的设置。性能模式持久化、下载监听器,都修了。"
"修了,"外交官推开门,屋里的灰尘在阳光中飞舞,"但只修了两个点。整间屋子还有好多地方没收拾。"
他亮出一份清单——五项,从 P1 到 P3,按优先级排列。
"这是审计时列出来但没来得及做的,"外交官说,"当时先做了红石子和黄石子,这些就排到了后面。"
"那现在做?"
"现在做,"外交官卷起袖子,"审计报告虽然写完了,但这间屋子不打扫干净,我走得不踏实。"
他把清单贴在墙上。五项任务,五幕戏。
第一幕:真正的持久化
第一幕关于记忆。
第二十二章里,性能模式的失忆症被治好了——用的是 localStorage。前端存一个标记,刷新页面的时候读回来。
"当时说'存 localStorage 就行',"AI 翻着旧笔记,"有什么问题?"
"localStorage 是前端的记忆,"外交官说,"关掉软件再打开,还在。但——"
"但?"
"但如果用户换了一台电脑呢?或者清了浏览器缓存呢?"
"那就没了。"
"就没了,"外交官点头,"而且,其他所有设置——模型库路径、外部软件、UI 缩放、主题色——都存在 Go 端的 config.json 里。唯独性能模式存在 localStorage 里。就像一栋房子,所有房间都锁了门,只有一间用绳子拴了一下。"
"不一致。"
"不一致,"外交官重复,"第二十二章的修复是急救——先止住血。现在要做的是根治——把性能模式也写进 config.json。"
他打开 app.go,在 UIState struct 里加了一个字段:
go
type UIState struct {
Scale float64 `json:"scale"`
PopupWidth int `json:"popupWidth"`
Accent string `json:"accent"`
FontFamily string `json:"fontFamily"`
Animations bool `json:"animations"`
BlurBg bool `json:"blurBg"`
PerformanceMode string `json:"performanceMode"` // ← 新增
}然后在 library.go 里加了一个 binding:
go
func (a *App) SetPerformanceMode(mode string) error {
return a.updateConfig(func(cfg *Config) {
cfg.UIState.PerformanceMode = mode
}, false)
}"前端呢?"
"前端的恢复逻辑早就写好了,"外交官指了指 main.ts,"第 473 行,if (s.performanceMode) setPerformanceMode(s.performanceMode)——当时就写好了,只是 Go 端一直没返回这个字段。"
"前端写好了后端没跟上?"
"前端写好了后端没跟上,"外交官叹了口气,"这种事在多人协作里经常发生——前端开发者写了读取逻辑,后端开发者忘了加字段。两边各自编译通过,但合在一起就是不通。"
"所以这是第二十二章的补完?"
"对,第二十二章治了标,这一幕治本。"
第二幕:拆墙
第二幕关于空间。
settings.ts 有 1118 行。其中 270 行是软件管理——扫描软件目录、构建软件列表、软件详情子菜单。
"270 行,占了整个文件的 24%,"外交官说,"但软件管理和设置页的核心逻辑没有关系。它只是碰巧住在同一栋楼里。"
"第三十章的绿石子里提过这个——大文件应该拆分。"
"提过,但当时只是'提了',没做,"外交官说,"现在做。"
他创建了一个新文件——settings-software.ts。把 scanSoftwareDir、buildSettingsSoftwareLevel、buildSoftwareDetailLevel 全部搬了过去。
"搬过去就完了?"
"没那么简单,"外交官摇头,"软件详情子菜单里调用了 settingsMenu.pop() 和 settingsMenu.reRender()。settingsMenu 是 settings.ts 里的局部变量,搬走之后就访问不到了。"
"循环依赖?"
"差点循环依赖,"外交官说,"settings-software.ts 如果 import settings.ts,而 settings.ts 又 import settings-software.ts——ESM 循环依赖,Vite 会报错。"
"怎么解决的?"
"用了一个不太优雅但很管用的办法——把 settingsMenu 挂到 window 上,通过 window.__getSettingsMenu() 来访问。"
"全局变量?"
"全局变量,"外交官承认,"不完美。但在不引入事件总线或依赖注入的前提下,这是最小改动方案。架构优化以后再说。"
他顿了顿,补充道:"重要的是——settings.ts 从 1118 行降到了大约 700 行。多 AI 同时改设置页的时候,冲突概率降低了不少。"
"24% 的瘦身。"
"24%,"外交官点头,"不算多,但够用。"
第三幕:兑现的承诺
第三幕关于一个写着"即将推出"的牌子。
设置页里有一个"清除缩略图缓存"的选项。点击它,什么也不会发生——因为旁边标着四个小字:即将推出。
"这牌子挂了多久了?"AI 问。
"不重要,"外交官说,"重要的是——今天摘掉它。"
他在 zipextract.go 里加了 ClearThumbnailCache:
go
func (a *App) ClearThumbnailCache() error {
cacheRoot, err := thumbnailDir()
if err != nil {
return err
}
entries, err := os.ReadDir(cacheRoot)
if err != nil {
return err
}
removed := 0
for _, entry := range entries {
if !entry.IsDir() {
os.Remove(filepath.Join(cacheRoot, entry.Name()))
removed++
}
}
a.safeLogInfo("ClearThumbnailCache: removed %d files", removed)
return nil
}"缩略图删了怎么办?模型不就没图了?"
"缩略图是可再生缓存,"外交官说,"模型加载的时候会自动截图重建。删了只是临时占位没了,不影响模型本身。"
"和提取缓存一样?"
"一模一样,"外交官说,"参照 ClearExtractCache 的模式写的——遍历目录、删文件、记日志。"
他在前端的点击事件里加了一个 confirm() 确认框,然后把"即将推出"四个字删了。
"终于兑现了。"
"终于兑现了,"外交官把那块虚拟的牌子扔进垃圾桶,"最怕的就是'即将推出'挂太久——挂着挂着就忘了。用户每次看到都以为快了,结果等了一年还在'即将'。"
"不如不挂?"
"不如不挂,"外交官同意,"没做就说没做。做了再说做了。'即将推出'是最诚实的谎言——你确实打算做,但你不知道什么时候做。"
第四幕:抄近路
第四幕关于距离。
设置页的菜单层级是这样的:
设置
└── 界面
└── 高级设置
├── 主题色 ← 第三层
└── 字体 ← 第三层"用户想换个主题色,要点三次——设置、界面、高级设置。然后才能看到六个色块。"
"三次点击换一个颜色?"
"三次点击换一个颜色,"外交官重复,"而且'高级设置'这个名字——什么算高级?主题色高级吗?字体高级吗?"
"不高级。"
"不高级,"外交官说,"主题色和字体是最常用的界面设置。把它们埋在'高级'里,是在惩罚用户。"
他把主题色和字体从第三层提到了第二层——直接内联到"界面"页面里。删掉了 buildSettingsUIAdvancedLevel、buildSettingsThemeLevel、buildSettingsFontLevel 三个函数。
重构后的层级:
设置
└── 界面
├── UI 缩放(滑块)
├── 弹窗宽度(滑块)
├── 主题色(6 色块 + hex) ← 直接可见
├── 字体(3 选项) ← 直接可见
├── 滑动动画(toggle)
├── 背景模糊(toggle)
└── 恢复默认"三层变两层。"
"三层变两层,"外交官点头,"用户进设置→界面,一眼就能看到主题色和字体。不用再钻一层了。"
"那'高级设置'这个入口呢?"
"没了,"外交官说,"本来就不该有。'高级'是一个懒惰的分类——你不知道该把东西放哪,就放'高级'里。用户也不知道'高级'里有什么,就不去点。"
"那真正高级的东西呢?"
"真正高级的东西,应该有自己明确的名字。不叫'高级',叫它本来的名字。"
第五幕:真正的重来
第五幕关于一个按钮——"恢复默认"。
"恢复默认"按钮之前只重置界面设置——缩放、宽度、主题色、字体、动画、模糊。
"听起来挺全的?"
"漏了两个,"外交官伸出两根手指,"显示名称优先级和性能模式。"
"用户改了'显示名称优先级'从文件名变成模型名,然后点'恢复默认'——"
"——性能模式也没恢复。用户调到'性能优先',点'恢复默认',界面回到默认了,但性能模式还是'性能优先'。"
"半恢复。"
"半恢复,"外交官说,"就像格式化硬盘但留了两个文件夹没删。用户以为全清了,其实没有。"
他在恢复默认的 click handler 里加了两行:
typescript
setDisplayNamePriority('filename');
SetDisplayNamePriority('filename').catch(() => {});
setPerformanceMode('auto');
SetPerformanceMode('auto').catch(() => {});"前一行改内存,后一行存后端?"
"对,"外交官说,"和前面的模式一样——前端状态 + Go 持久化。两步都做,才算真的恢复。"
"那外部库和软件管理呢?也恢复?"
"不恢复,"外交官摇头,"外部库和软件管理是用户数据,不是偏好设置。恢复默认只清偏好——UI 外观、显示方式、性能策略。用户自己装的软件、配的路径,不能动。"
"边界。"
"边界,"外交官点头,"恢复默认不是格式化,是重置偏好。要分清什么是偏好、什么是数据。"
尾声:可点击的文字
五幕演完了。外交官准备收工。
AI 在测试的时候发现了一个额外的问题。
"你说滑块——cs-row 那个组件,"AI 说,"点击数值文本能不能调?"
"什么意思?"
"就是——cs-row 上面一行是图标 + 标签 + 数值,下面是滑条。点滑条可以调,但点上面的文字——"
"点上面的文字不行?"
"不行,"AI 确认,"点击事件只绑在 cs-bar 上。cs-top 区域——就是图标、标签、数值那一行——没有点击事件。"
外交官试了一下。果然,点"动作强度 0.45"这行文字,什么都不会发生。必须点下面的滑条才行。
"但 settings.ts 里那个 addCsRow 不是有 row 级别的点击事件吗?"外交官翻开代码,"看——它根据点击位置分区调整:左 25% 大减,左 50% 小减,右 75% 小加,右 100% 大加。"
"对,但那个是 settings.ts 里的 addCsRow,"AI 指出,"ui-helpers.ts 里的 addSliderRow 没有。这是两个不同的函数——一个只在设置页用,一个在全应用用。"
"所以场景菜单、环境菜单、动作弹窗里所有的滑块——"
"——都点不了文字。"
外交官沉默了一会儿。
"多少个滑块受影响?"
"grep 了一下,addSliderRow 被调用了九十多次。"
"九十多个滑块都点不了文字。"
"都点不了文字。"
外交官在 ui-helpers.ts 里找到了 addSliderRow,在 row 上加了和 settings.ts 的 addCsRow 一样的分区点击逻辑。同时在 bar 的点击事件里加了 e.stopPropagation()——否则点击滑条会同时触发 row 的点击事件,等于调两次。
typescript
row.addEventListener('click', (e) => {
const rect = row.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
let delta: number;
if (x < 0.25) delta = -(range * 0.15);
else if (x < 0.5) delta = -(range * 0.05);
else if (x < 0.75) delta = range * 0.05;
else delta = range * 0.15;
let newVal = snapToStep(currentValue + delta);
newVal = Math.max(min, Math.min(max, newVal));
if (newVal !== currentValue) {
updateDisplay(newVal);
onChange(newVal);
onDragEndCb?.(newVal);
}
});"和 addCsRow 一模一样的逻辑?"
"一模一样,"外交官说,"左减右加,越靠边幅度越大。用户点左边就是减,点右边就是加,点中间偏一点就微调。"
"那为什么有两个函数?addCsRow 和 addSliderRow?"
"历史原因,"外交官叹气,"addCsRow 是设置页自己写的,addSliderRow 是 ui-helpers 的公共函数。两个长得几乎一样,但点击行为不一样。"
"应该合并?"
"应该合并,"外交官点头,"但那是以后的事。今天先把点击修了——九十多个滑块,全都能点文字了。"
五幕之后
五幕加一个尾声。设置页终于打扫干净了。
外交官重新收拾行李。这次是真的要走了。
"总结一下?"AI 问。
"五件事,"外交官掰着手指:
| 幕 | 做了什么 | 一句话 |
|---|---|---|
| 一 | 性能模式写进 config.json | 前端写好了后端终于跟上 |
| 二 | 软件管理拆出 settings-software.ts | 1118 行降到 700 行 |
| 三 | 缩略图缓存清除兑现 | "即将推出"摘牌 |
| 四 | 主题色/字体从三层提到两层 | 消灭"高级设置"入口 |
| 五 | 恢复默认覆盖全部偏好 | 分清偏好与数据的边界 |
| 尾声 | 滑块全行可点击 | 90+ 个滑块修好 |
"六件事,"AI 纠正,"不是五件。"
"尾声不算正剧,"外交官笑了,"五幕就是五幕。"
他拿起那份贴在墙上的清单——五个任务全画了勾。把清单揭下来,叠好,放进口袋。
"走吧,"他说,"这次是真的走。"
补记:生命周期的两堂课
设置页的整顿不是第一次。在五幕之前,有两件"小事"已经先修过了——它们合在一起,指向同一个教训:生命周期管理。
第一课:性能模式的失忆。 性能模式开关存在前端内存里,刷新就丢。方案:存 localStorage,初始化读,修改时写。两行代码的原因:持久化不在功能的核心路径上——用户点开关→变量变了→行为变了,这三步是"功能做完了"的判断标准。第四步"关掉再打开还在"不在检查清单里,所以忘。
第二课:消失的监听器。 下载扫描完成的事件监听器绑在 dirInput DOM 元素上。用户切到别的设置页,dirInput 被销毁重建——监听器没了。再切回来,新元素没绑监听器。扫描完成的事件永远收不到了。方案:每次渲染 dirInput 时重新绑定监听器。
"你发现没有,"外交官说,"这两个问题,本质上是同一个问题——生命周期没管好。性能模式是'创建时没读,销毁时没写'。下载监听是'创建时没绑,销毁时没解'。"
"一个功能的生命周期,创建时做什么(初始化/读配置/绑事件)?更新时做什么(数据变了怎么同步)?销毁时做什么(存配置/解事件/清资源)?三个问题想清楚,80% 的 bug 都不会出现。"
教训:很多前端 bug,说穿了就是生命周期没管好。该存的没存、该绑的没绑、该清的没清。这些东西不在"核心功能"里,但一个成熟的软件,就是把这些"不是核心"的小事都做好。
附录:设置页改进检查清单
| 检查项 | 说明 |
|---|---|
| 持久化一致性 | 所有有状态的功能,持久化方式统一(要么都存 localStorage,要么都存后端 config) |
| 文件大小 | 单文件超过 800 行时考虑拆分;超过 1200 行必须拆分 |
| 承诺兑现 | "即将推出"标记必须有对应的待办事项;超过两周未实现的摘牌 |
| 导航深度 | 常用功能不超过两层菜单;不常用的不超过三层 |
| 恢复默认边界 | 恢复默认只清偏好设置,不清用户数据 |
| 点击区域 | 交互组件的可点击区域应覆盖整个视觉行,而非仅内部子元素 |
教训:一间屋子打扫完了,不代表每个角落都干净了。审计报告写完了,不代表遗留问题都解决了。真正的收尾,是回头再看一眼——那个你以为已经收拾好的房间,推开门,灰尘还在阳光里飞舞。