首页
/ LobeHub Resource 模块 UX 审计实战:定位并系统性修复“失败伪装成空状态”的四状态门问题

LobeHub Resource 模块 UX 审计实战:定位并系统性修复“失败伪装成空状态”的四状态门问题

2026-09-06 17:11:06作者:凤尚柏Louis

本文以 LobeHub 仓库中一次真实的 UX 审计工作样例(Resource 资源/知识库模块,2026-07,工单 LOBE-11149)为主体,完整还原一次标准化、可复现的界面审计如何展开:如何为模块命名“界面类别”并对标成熟产品、如何用三层审计模型(静态代码 / 视觉截图 / 动态旅程)得出有证据的结论、如何把发现的体验缺陷按严重度排序并回灌到 ux 检查清单。读完后,你既能掌握这套“证据而非感觉”的审计方法,也能看到其中最有代表性的系统性缺陷——“数据加载失败被渲染成引导空状态”——在 LobeHub 源码中是如何被识别、并以共享的 AsyncBoundary 四状态门组件系统性收口的。

原始审计文档位于 resource.md,审计方法论定义在 SKILL.md。原文档明确提醒:该样例是输出形态的模板,不是当前状态的真相,引用前须重新核对——本文随后一节会对照当前源码逐一验证,这也正是该审计“闭环”设计的意义所在。

一、审计对象:Resource 模块的三级界面结构

Resource 模块是 LobeHub 的知识库/文件库管理器(knowledge-base / file-library manager),包含三级界面:

界面 路由 职责
resource home(资源首页) src/routes/(main)/resource/(home)/ 所有资源总览、侧边栏知识库列表
library(单个知识库) src/routes/(main)/resource/library/ 单个知识库的浏览、文件夹层级
library-slug(库内文件夹) src/routes/(main)/resource/library/[slug]/ 库内具体文件夹的内容视图

三个界面(surface)共用同一个 feature 实现 src/features/ResourceManager/,数据层落在 src/store/file/slices/{resource,library,tree}。路由与实现的映射关系可以从目录结构直接确认:library/index.tsx/resource/library/index.tsx) 直接 export { default } from '@/features/ResourceLibrary',即路由只做装配,业务逻辑全部下沉到 feature 层。

审计时为该模块命名的界面类别是 knowledge-base / file-library / resource manager,对标 Notion、Google Drive / Dropbox、NotebookLM、Mem、Obsidian。这一“类别规范(class norms)”检查清单直接决定了审计的基线能力:

  • 带进度条的上传(upload-with-progress)
  • 搜索 / 过滤(search/filter)
  • 排序(sort)
  • 多选 + 批量操作(bulk-select + bulk-ops)
  • 单项生命周期管理(single-item lifecycle)
  • 文件预览(file preview)
  • 条目元数据:大小 / 类型 / 日期 / 嵌入状态(item metadata)
  • “空即引导”(empty-as-onboarding)与“无匹配”(no-match)的区分
  • 分页 / 无限滚动(pagination / infinite scroll)

这套类别基准是审计方法论中的关键 ground rule:只读自家代码在结构上对“压根没建的能力”是盲的——一个完全缺席的 affordance 没有 file:line 可 grep。所以审计必须先写下“这个类别的成熟产品会提供什么”,再对着找差距,否则审计只会不断打磨已有路径、无意中放过整片缺失。

二、审计方法与覆盖矩阵:为什么本次只跑了 L1

SKILL.md 定义了三层审计模型,核心原则是“结论必须来自能看见它的层”:

做什么 能抓住什么 成本
L1 静态 读代码 缺失的状态/分支(空/错误/重试)、草稿不持久、模式缺席、结构性问题 低,离线,每次必跑
L2 视觉 渲染界面截图 真实视觉层级与主导控件、间距/对比度/对齐、截断溢出、空/加载/错误态的真实长相、响应式断点、深色/浅色 中,需要渲染环境
L3 动态 用 acceptance 框架驱动真实用户旅程 + 插桩 进行中/锁定态、强制触发错误/空态、步骤连贯性、焦点/键盘可达、量化 CLS / LCP / INP / 长任务 高,需要运行环境与鉴权

覆盖矩阵中的关键警示:缺失 empty/error 分支、无重试、草稿不持久这类问题 L1 可以下结论;而视觉层级、响应式、以及“CLS/LCP/INP 数字”“两个变体谁更好”这类结论 只能来自 L2/L3——对着代码里的 variant prop 打勾“只有一个主按钮”是典型的误判陷阱。

本次 Resource 审计实际运行的层:L1(静态/代码)✅,L2 / L3 ⏳ 未运行(待验证项见第五节)。这个标注本身就是规范的一部分:报告必须声明“哪一层跑了”,防止读者把 L1 的静态推断当成已验证的运行行为。

三、结论先行:写入侧很强,读取侧没有任何错误路径

审计的 Headline 结论:写入/摄入(write / ingestion)侧确实很强——拖拽上传带实时进度坞、按文件夹的树懒加载、虚拟化无限滚动、三模式多选、批量删除/分块带确认、乐观创建/重命名。但读取侧在任何地方都没有错误路径:四次数据获取(资源列表、侧边栏知识库列表、搜索、文件夹树)全部只读 isLoading/data,从不读 error,于是失败的获取被渲染成引导空状态——加载其实坏了,却告诉用户“创建你的第一个资源”。另有两处吞错陷阱叠加:知识库详情获取失败被渲染成 404 Not Found(把“被删除”和“加载失败”混为一谈);上传坞会在 3 秒后自动关掉 失败 的上传,且没有重试。

与 Eval 模块的根因相同——“仅在成功时才 resolve”的系统性问题,可对照 eval.md 样例。

3.1 在用的模式(Patterns in use)

审计按模式族列出该模块“在用哪些模式、用得多好”:

模式(族) 位置 评级 备注
Overview + Detail(导航) home → library → folder(slug);侧边栏树钻取 干净的多级钻取
Deep-linking(导航) /resource/library/:id/:slug 恢复文件夹;树展开祖先 面包屑驱动的祖先链展开
Empty-as-onboarding(增长) Explorer EmptyPlaceholder(建库/传文件/传文件夹卡片) 真实页面 + CTA,亮点
Loading Skeleton(反馈) 列表/瀑布骨架复用行/卡片外观;侧边栏 SkeletonList;树 TreeSkeleton 教科书级 §4.1
Failure + Retry(反馈) 全部四次获取(资源列表、KB 列表、搜索、树) — 缺席 系统性根因(差距 A)
Progress Indicator(反馈) UploadDock 每文件 + 总体进度条 但会自动关闭失败项(差距 C)
List at scale(数据) Virtuoso / VList 虚拟滚动 + 无限 endReached + 尾部骨架 主列表扎实
Search over paginated set(数据) SearchResultsOverlay 服务端查询硬编码 limit: 50, offset: 0 ⚠️ 50 条之后无分页(差距 D)
实体生命周期 — 删除/分块(操作) 批量删除 + 批量分块:确认 + 异步 有确认
实体生命周期 — 创建/重命名(操作) 建库 Modal、内联重命名、乐观资源操作 ✅/⚠️ 乐观更新;重命名草稿仅内存(差距 E)
Upload(输入/操作) 拖拽区 + UploadDock ✅/⚠️ 强,但失败无重试(差距 C)
Draft safety(编辑) 搜索词 → URL+store(可存活);重命名输入 → 本地 useState ⚠️ 重命名草稿不持久(差距 E,轻微)

一句话读法:摄入、树、虚拟化、多选与删除/分块都很扎实;弱点完全聚集在读取侧——列表/详情/搜索/树的 error + 重试——外加上传坞静默丢弃失败项。

四、亮点清单:不要回退(Strengths / good cases)

写入侧在关键处做得对,这些是“回灌循环”的 ✅ 半边,也是下次重构的“不要回退”清单——保留,而不是“修复”:

  • ✅ 亮点 — 带实时进度坞的拖拽上传。 拖拽区把文件送入 UploadDock,摄入过程中同时呈现每文件与总体进度条——摄入侧从不失声。(唯一保留意见:它会自动关闭 失败 的上传;那是差距 C,不是对快乐路径坞的否定。)
  • ✅ 亮点 — 规模化列表做对了。 虚拟列表 瀑布流Virtuoso / VList)配无限 endReached 分页加尾部骨架,主列表在规模下依然流畅,“加载更多”的暗示是诚实的而不是硬上限。
  • ✅ 亮点 — 按文件夹懒加载的树 + 祖先自动展开。 文件夹树按节点加载(每文件夹懒加载、每节点独立 loading 态),深链进入时自动展开整条祖先链,把文件夹原地恢复——即 /resource/library/:id/:slug 的面包屑驱动祖先展开。
  • ✅ 亮点 — 三模式多选 + 安全的批量操作。 三种模式(none / loaded / all)的多选,驱动批量删除批量分块,两者都在确认之后异步执行——破坏性批量操作从不裸奔。
  • ✅ 亮点 — 乐观创建/重命名与服务端数据合并。 建库 Modal、内联重命名与乐观资源操作通过 mergeServerResourcesWithOptimistic 与服务端真值合并——列表立即更新且不与服务器真相分叉。该函数在 hooks.ts 中被 useFetchResources 的同步 effect 调用,把 SWR 缓存数据合并进 store 镜像后再写入,保证缓存命中路径(切回曾加载过的文件夹)也不会显示上一文件夹的旧数据。
  • Explorer EmptyPlaceholder + 复用外观的骨架。 EmptyPlaceholderEmptyPlaceholder.tsx)是真正的引导页(建库/传文件/传文件夹卡片),列表/瀑布/侧边栏/树骨架全部复用行/卡片外观,实现原位“加载→内容”替换——全程没有 antd Spin。一个安静的反馈卫生胜利,值得保留。

五、体验差距(按严重度排序)

严重度标尺(SKILL.md 共享定义):🔴 破坏信任——数据/输入丢失、卡死/永久状态、把失败伪装成“空”、静默发送失败;🟠 死路或误导——没有前进路径、歧义状态、缺进行中反馈、不是真实页面的空状态;🟡 摩擦/不一致/错失惊喜

🔴 A — 任何读取都没有错误/重试;每次获取只在成功时 resolve → 加载失败被渲染成引导空状态(或空白、或假 404)。 系统性问题:四个消费方都只解构 { data, isLoading }(从不读 error),把失败强行压成空:

  • Explorer 假空useFetchResources 返回含 error 的完整 swr(hooks.ts,审计时点为 :104),但消费方只解构 { isLoading, isValidating }(当时 Explorer/index.tsx:57),并以 showEmptyStatus = !isLoading && !isValidating && data?.length === 0:89)为门 → 获取失败 → 空列表 → “创建你的第一个资源”引导页(EmptyPlaceholder.tsx:65)。
  • 侧边栏 KB 列表const { data, isLoading } = useFetchKnowledgeBaseList()(当时 (home)/_layout/Body/LibraryList/index.tsx:22);没有 error,失败时渲染“新建库”空 CTA 或空白列表(:38-48)。
  • 搜索浮层假空const { data, isLoading } = useClientDataSWR(...)(当时 SearchResultsOverlay.tsx:43);搜索失败 → !data → “No results found”(:113),在查询实际报错时断言“无匹配”。
  • 文件夹树假空isLoading = status[''] === 'loading';空态门在 !isLoading && … visibleNodes.length === 0(当时 LibraryHierarchy/index.tsx:60,83),无 'error' 分支 → 树加载失败渲染“新建文件夹”空态。

对应规则 Read §1.1(error 先于 empty)+ Feedback §4.2。error 信号本就存在于 swr 返回值上,只是从未被读——这是一个廉价修复,却是该模块的头号 ❌ 案例。

🟠 B — 知识库详情:获取失败渲染成 404 “Not Found”,把“被删除”与“加载失败”混为一谈,且无重试。 useKnowledgeBaseItem 被读作 { data, isLoading },然后 if (!isLoading && !data) return <NotFound />(当时 resource/library/index.tsx:21,37)。一次瞬时网络/500 → data 未定义 → 永久的“不存在”404,用户以为自己的库被删了,且没有 Reload 可用。对应 Read §1.1(加载失败不是“没找到”;区分 deleted vs errored,提供重试)。

🟠 C — 上传坞在 3 秒后自动关闭 失败 的上传,且没有重试。 自动关闭的 effect 只守卫 isUploading'pending''error' 状态直接穿透,3 秒后隐藏坞并 removeFiles(当时 UploadDock/index.tsx:106-124)。失败上传因此静默消失——错误不保留,任何地方都没有重试入口。自动关闭应只适用于成功;失败必须持久存在并带重试。对应 Feedback §4.2(失败态保持可用 + 提供重试)、Act §3.1(异步操作以 done/error 结束,而非发后即忘)。

🟡 D — 搜索结果硬上限 50 条且无分页。 搜索获取硬编码 limit: 50, offset: 0(当时 SearchResultsOverlay.tsx:52-57),渲染一个扁平虚拟化列表且无“加载更多”——第 51 条及以后的匹配不可达。服务端查询本身是正确的(不是半页假空),但界面静默截断。对应 Read §1.2(对大结果集的搜索必须能翻过全部匹配,不能静默设限)。

🟡 E — 内联重命名草稿是内存态 useState,编辑中途关闭行/弹层即丢失。 轻微(按 Edit §2.1,短暂的内联编辑标准更轻),但被点走打断的重命名会丢掉已输入的名称且无法恢复(当时 (home)/_layout/Body/LibraryList/Item/Editing.tsx,对应现仓库 Editing.tsx)。对应 Edit §2.1。

🟡 F — 搜索浮层持有自己独立的 selectedFileIds 本地状态,与主 Explorer 的选中集互不相通。 在搜索结果里选行不会带入常规视图(反之亦然,当时 SearchResultsOverlay.tsx:34,现仓库见 SearchResultsOverlay.tsx)。这是选中一致性的缺口;应在 L2 验证预期模型。对应 Act §3.1(批量操作对等)、Read §1.6 附近(跨视图的状态连续性)。

六、源码级对照:审计之后,修复如何落地

原文档提醒“引用前重新验证”,这里对照当前仓库源码逐条核对差距的修复状态,可以清楚看到“审计 → 回灌 → 修复”闭环的实际效果。

差距 A(四个读取全部吞错)——已系统性收口。 当前代码中四处消费方都改为读取 errormutate,并把失败分支置于空态之前:

  • ExplorerExplorer/index.tsx 注释直接交代了来龙去脉——“error / mutate were previously discarded, so a failed resource fetch fell through to the 'create your first resource' onboarding empty (Read §1.1 failure-as-empty). Capture them and branch the failure before empty.” 渲染侧用共享组件 AsyncBoundary 做仲裁:<AsyncBoundary data={data} empty={<EmptyPlaceholder />} error={error} isEmpty={showEmptyStatus} onRetry={() => mutate()}>index.tsx)。
  • 侧边栏 KB 列表LibraryList/index.tsx 同样注明“fallbackData: [] keeps data an array even on failure, so a failed KB-list fetch used to render the 'create your first library' empty”,现在解构 { data, isLoading, isValidating, error, mutate } 并以 errorVariant={'inline'}AsyncBoundary 渲染失败态(index.tsx)。该文件还顺带修了一个更隐蔽的问题:模式切换后的首次获取因 fallbackData: [] 会让 isLoading 立刻塌缩为 false,需以 isValidating 补位,否则侧边栏会在网络往返期间闪一下空态(state.tsgetLibraryListAsyncState 负责仲裁)。
  • 搜索浮层SearchResultsOverlay.tsx 现在的分支顺序是 isLoading → error && 无数据 → 真无匹配 → 结果,注释明确“A failed search fetch used to fall through to the 'no results' state… Branch the failure before the no-match state; the no-match variant below is untouched and still handles a genuine zero-result search.”——失败先分支,真·零结果语义保持独立。

差距 B(详情失败渲染 404)——已修复并细分 403。 ResourceLibrary/index.tsx 现在的判断顺序是:if (error && !data) 先渲染页面级 AsyncErrorvariant={'page'})并提供重试;if (!isLoading && !data) return <NotFound /> 只保留给真正解析为 null(已删除/从未存在)的记录。注释即规则本身:“A network / 500 on the KB fetch is NOT 'this library doesn't exist' (Read §1.1)”。更细的一处:受限知识库(use 级权限)导致的 403 被识别为 isForbiddenError,此时不给重试按钮——因为重试永远不会成功,页面就地用共享 403 文案说明原因(index.tsx)。

差距 D(搜索硬上限 50)——当前源码仍可确认存在。 SearchResultsOverlay.tsx 的 fetcher 依旧硬编码 limit: 50, offset: 0,列表是扁平虚拟化渲染、无 load-more。这是本次审计中少数“审计后仍未落地”的 🟡 项,可作为对照:🔴/🟠 项被系统性组件化修复,🟡 项按排期逐步处理。

差距 C(上传坞自动关闭失败项):审计时的 UploadDock 路径在仓库重构后已不原样存在;从源码结构看,上传进度坞的任务接入目前落在 TaskDock 之下(如 useFileUploadDockTasks.tsx)。该项的最终验证属于 L3 待办(见下节),本文不对其当前状态做断言。

共享修复件:AsyncBoundary 四状态门。 上述三处修复的共同落点是 src/components/AsyncBoundary/index.tsx——一个把“loading / error / empty / data”四态仲裁集中化的组件。它的注释与实现值得单独看:

  • 存在理由(index.tsx):代码库的获取约定只建模了 loading + success——SWR 的 error 被返回但被丢弃,于是失败获取落成永久骨架、假引导空态或自信的“$0”。AsyncBoundary 在空态分支之前读 error,一次渲染对的状态,调用方不必各自手搓优先级。
  • 优先级:loading → error → empty → children;且只有当 fetch 从未成功落地data === undefined)时错误才抢占全屏——后台刷新失败不会吹掉已落地的内容(包括已落地的空列表)。
  • 一个易被忽略的细节:loading 读在 error 之前,因为 SWR 在重试期间会保留上一个 error,若先判 error,用户点 Retry 后看到的是“冻结的错误块 + 一个仍可点击的 Retry”;先判 loading 则显示骨架,反馈更诚实。

迁移任意一个数据面到这套约定是机械操作:从 hook 取回 { data, error, isLoading, mutate },用 <AsyncBoundary data={data} error={error} isLoading={isLoading} onRetry={mutate} …> 包住渲染即可(index.tsx)。这正是差距 A 被称为“廉价修复”的原因——信号本来就在,缺的只是读它。

七、Skill 反馈(回灌 ux 检查清单)

审计不是写完发现就结束了——SKILL.md 要求每次运行必须把可泛化发现**回灌(回灌)**到 ux 技能,让检查清单比审计前更锋利。本次 Resource 审计的回灌结果:

  • 新增 / 强化规则:
    • Feedback §4.2 — 增加条款 + ❌ 示例:短暂/自动消失的状态面(上传坞、进度 toast)不得自动清除失败项——自动关闭只成功;失败项保留并带重试。 Resource 的 UploadDock(差距 C)是 ❌。
    • Read §1.1 — 增加 detail-fetch-failure-as-404 条款:把详情获取失败渲染成“not found / 404”与把失败伪装成空态是同一种错误伪装(deleted vs failed-to-load);Resource 的 library/index.tsx(差距 B)是 ❌。
  • 落地为既有规则的 ❌ 示例(规则本身有效,无需新增):
    • Read §1.1 — Resource Explorer / 侧边栏 KB 列表 / 搜索浮层 / 文件夹树加入“失败即空态”示例(差距 A)。
    • Feedback §4.2 — “四次获取从不读 error”的模式(差距 A),与 Eval 模块 onSuccess-only 根因并列。
    • Read §1.2 — 搜索截断 50 条无分页(差距 D)。
  • 验证了既有规则: §1.1 empty-vs-failed、§4.2 loading-can-fail、Act §3.1 done/error。

同时,审计文档本身按规范存档为 references/example/<page>.md,供下一次运行当模板——本文所引用的 resource.md 即其一,同目录还有 home.mdeval.mdtask-detail.md 等一整套样例,构成该技能的“判例库”。

八、待办:L2 + L3 验证清单

审计明确列出了尚未运行的层及其要验证的问题:

  • L2(视觉) — 假空引导在失败获取 vs 真空时实际观感差异;窄宽度下的瀑布卡片网格;上传坞的进度/错误视觉;深色模式。
  • L3(动态) — 强制四次获取逐一失败,现场确认差距 A 的四种形态(引导空 / 空白 / 假“无结果” / 假“新建文件夹”);在知识库详情获取失败时打开该库,确认差距 B(假 404);强制一次上传失败并观察坞的自动关闭(差距 C);搜索一个匹配数超过 50 的库,确认差距 D 的截断。

其中前两项在第六节的源码对照中已被“静态确认”修复,L3 的价值在于按覆盖矩阵的要求,把 L1 的静态结论升级为可复现的运行证据。

九、可复用的方法论要点

从这次 Resource 审计中可以提炼出几条不依赖具体模块、可直接套用到自己项目前端审计的规则:

  1. 错误分支必须先于空态分支。 SWR/React Query 等 hook 的 error 信号常常就在返回值里,被丢弃的只是解构。{ data, error, isLoading, mutate } 四件套 + “loading → error → empty → data”固定优先级,是把整类“失败伪装”一次性消灭的最低成本方案;LobeHub 将其沉淀为共享组件 AsyncBoundary 并配了 index.test.tsx 测试,避免每个调用点各写一遍优先级。
  2. 详情失败 ≠ 404。 区分“解析为 null(已删除/不存在)”与“请求报错(瞬时故障)”,前者才是 404,后者应提供重试;403 之类“重试无意义”的错误则应就地说明原因而不给重试按钮(ResourceLibrary/index.tsx 的三段式处理是范例)。
  3. 自动消失的反馈面只对成功自动消失。 进度坞、toast 的 auto-dismiss 守卫必须显式排除 'error' 态。
  4. 审计先定类别再读代码。 没有对标成熟产品的“应有能力清单”,代码审计只会打磨已有路径、放过整片缺失。
  5. 审计是闭环。 每次运行要么回灌新规则/新示例,要么在报告中显式声明“本次仅验证既有规则”——沉默不是合法收尾;且样例报告本身存档为判例,让下一次审计有模板可套。

三层模型、覆盖矩阵与严重度标尺的完整定义,可继续深入 SKILL.mdlayer-1-static.mdlayer-2-visual.mdlayer-3-dynamic.mdpattern-catalog.md;本文引用的所有源码路径均可在当前仓库中直接核对。

登录后查看全文
热门项目推荐
相关项目推荐