Skip to content

设置页的五幕

背景:设置页性能模式不持久、软件管理未拆分、缩略图缓存不清除。

过程:五阶段系统性改进——持久化/拆分/缓存清除/导航缩短/恢复默认。


审计报告写完了。

三颗红石子、十五颗黄石子、三十颗绿石子,分门别类装进了盒子。外交官收拾好行李,准备离开。

他走到门口,回头扫了一眼整座城邦。

港口在运转,议会在立法,织布机在嗡嗡作响,穹顶之下一切井然。

但他的目光停在了角落里的一扇门上。

那扇门上写着两个字:设置

"等一下,"外交官放下行李,"这间屋子,好像还没彻底打扫过。"


为什么是设置页

"设置页不是已经审计过了吗?"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。把 scanSoftwareDirbuildSettingsSoftwareLevelbuildSoftwareDetailLevel 全部搬了过去。

"搬过去就完了?"

"没那么简单,"外交官摇头,"软件详情子菜单里调用了 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() 确认框,然后把"即将推出"四个字删了。

"终于兑现了。"

"终于兑现了,"外交官把那块虚拟的牌子扔进垃圾桶,"最怕的就是'即将推出'挂太久——挂着挂着就忘了。用户每次看到都以为快了,结果等了一年还在'即将'。"

"不如不挂?"

"不如不挂,"外交官同意,"没做就说没做。做了再说做了。'即将推出'是最诚实的谎言——你确实打算做,但你不知道什么时候做。"


第四幕:抄近路

第四幕关于距离。

设置页的菜单层级是这样的:

设置
└── 界面
    └── 高级设置
        ├── 主题色    ← 第三层
        └── 字体      ← 第三层

"用户想换个主题色,要点三次——设置、界面、高级设置。然后才能看到六个色块。"

"三次点击换一个颜色?"

"三次点击换一个颜色,"外交官重复,"而且'高级设置'这个名字——什么算高级?主题色高级吗?字体高级吗?"

"不高级。"

"不高级,"外交官说,"主题色和字体是最常用的界面设置。把它们埋在'高级'里,是在惩罚用户。"

他把主题色和字体从第三层提到了第二层——直接内联到"界面"页面里。删掉了 buildSettingsUIAdvancedLevelbuildSettingsThemeLevelbuildSettingsFontLevel 三个函数。

重构后的层级:

设置
└── 界面
    ├── 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.ts1118 行降到 700 行
缩略图缓存清除兑现"即将推出"摘牌
主题色/字体从三层提到两层消灭"高级设置"入口
恢复默认覆盖全部偏好分清偏好与数据的边界
尾声滑块全行可点击90+ 个滑块修好

"六件事,"AI 纠正,"不是五件。"

"尾声不算正剧,"外交官笑了,"五幕就是五幕。"

他拿起那份贴在墙上的清单——五个任务全画了勾。把清单揭下来,叠好,放进口袋。

"走吧,"他说,"这次是真的走。"


补记:生命周期的两堂课

设置页的整顿不是第一次。在五幕之前,有两件"小事"已经先修过了——它们合在一起,指向同一个教训:生命周期管理。

第一课:性能模式的失忆。 性能模式开关存在前端内存里,刷新就丢。方案:存 localStorage,初始化读,修改时写。两行代码的原因:持久化不在功能的核心路径上——用户点开关→变量变了→行为变了,这三步是"功能做完了"的判断标准。第四步"关掉再打开还在"不在检查清单里,所以忘。

第二课:消失的监听器。 下载扫描完成的事件监听器绑在 dirInput DOM 元素上。用户切到别的设置页,dirInput 被销毁重建——监听器没了。再切回来,新元素没绑监听器。扫描完成的事件永远收不到了。方案:每次渲染 dirInput 时重新绑定监听器。

"你发现没有,"外交官说,"这两个问题,本质上是同一个问题——生命周期没管好。性能模式是'创建时没读,销毁时没写'。下载监听是'创建时没绑,销毁时没解'。"

"一个功能的生命周期,创建时做什么(初始化/读配置/绑事件)?更新时做什么(数据变了怎么同步)?销毁时做什么(存配置/解事件/清资源)?三个问题想清楚,80% 的 bug 都不会出现。"

教训:很多前端 bug,说穿了就是生命周期没管好。该存的没存、该绑的没绑、该清的没清。这些东西不在"核心功能"里,但一个成熟的软件,就是把这些"不是核心"的小事都做好。


附录:设置页改进检查清单

检查项说明
持久化一致性所有有状态的功能,持久化方式统一(要么都存 localStorage,要么都存后端 config)
文件大小单文件超过 800 行时考虑拆分;超过 1200 行必须拆分
承诺兑现"即将推出"标记必须有对应的待办事项;超过两周未实现的摘牌
导航深度常用功能不超过两层菜单;不常用的不超过三层
恢复默认边界恢复默认只清偏好设置,不清用户数据
点击区域交互组件的可点击区域应覆盖整个视觉行,而非仅内部子元素

教训:一间屋子打扫完了,不代表每个角落都干净了。审计报告写完了,不代表遗留问题都解决了。真正的收尾,是回头再看一眼——那个你以为已经收拾好的房间,推开门,灰尘还在阳光里飞舞。