EXAM-20260730-01 阅卷报告:Finding 反馈交互链路
总览
- 考生:Dano Day
- 得分:60 / 80(75%)
- 评级:🌕🌕🌕🌑🌑 — 及格,有需改进之处
- 致命问题:2 个
- Git 标准答案基准:
4e03802a81989b0ae6da6ca775d9fa789a93e773 - 试卷文件:
apps/app/src/hooks/use-finding-feedback.tsapps/app/src/components/finding-feedback-buttons.tsxapps/app/src/pages/RuleDetailPage/RuleDetailPageContent.tsx
本次继续遵守 i18n 不计分边界:翻译 key、直接文案和具体措辞不扣分。按钮能否理解、状态是否完整、事件是否隔离和数据能否刷新仍按功能评分。
成绩汇总
| 题号 | 文件 | 知识点 | 得分 | 结论 |
|---|---|---|---|---|
| 1 | use-finding-feedback.ts | React Query 查询与缓存键 | 4/10 | 首次查询可用,但查询 key 与失效 key 不一致,失败也没有真正收敛为 null |
| 2 | use-finding-feedback.ts | mutation payload 与缓存失效 | 5/10 | 提交代码行为正确,但考生明确标注为抄写,不计为独立掌握 |
| 3 | finding-feedback-buttons.tsx | 事件冒泡、pending 守卫 | 10/10 | 三个回调的事件隔离、值映射和依赖均正确 |
| 4 | finding-feedback-buttons.tsx | JSX 事件接线、可访问名称 | 8/10 | 三个按钮接线正确;擅自移除图标及 import,留下调试说明 |
| 5 | RuleDetailPageContent.tsx | Effect、异步加载、payload | 9/10 | 加载生命周期和 { ruleId } 正确;双重断言掩盖了类型关系 |
| 6 | RuleDetailPageContent.tsx | 早返回、Refine update | 10/10 | 前置条件、不可变 values 和依赖数组全部正确 |
| 7 | RuleDetailPageContent.tsx | 异步三态渲染 | 10/10 | loading → empty → data 顺序正确,使用等价组件不扣分 |
| 8 | RuleDetailPageContent.tsx | 可点击行与反馈组件组合 | 4/10 | Props 正确,但行没有 onClick,展开功能完全断开 |
| 合计 | 60/80 | 75% |
逐题批改
第1题:读取单条 Finding 的反馈状态 — 4/10
一句话结论:当前代码能完成首次查询,但提交反馈后不会刷新这条查询,因为“存数据时使用的地址”和“通知刷新时使用的地址”不是同一个。
考生代码:
const { data } = useQuery({
queryKey: [findingId],
queryFn: () =>
trpcClient.findings.getFeedbackByFinding.query({ findingId }) ?? null,
enabled: findingId.length > 0,
}, queryClient);
必须修复:查询 key 与失效 key 不一致
概念是什么:查询键(queryKey)是 React Query 给缓存数据使用的结构化地址。数组第一项通常是业务命名空间,后续项用于区分具体实体。
本题运行过程:
FeedbackButtons 渲染
→ useQuery 把结果存到 [findingId]
→ 用户提交反馈
→ 第2题失效 ["finding-feedback", findingId]
→ React Query 找不到同一个缓存地址
→ 原来的 [findingId] 不重新查询
→ currentOutcome 可能继续显示旧值
这不是要求所有项目都必须使用某个固定字符串,而是要求同一份数据的查询和失效采用同一套 key 工厂或同一结构。
最小修复:
const feedbackKey = ["finding-feedback", findingId] as const;
useQuery({ queryKey: feedbackKey, /* ... */ });
queryClient.invalidateQueries({ queryKey: feedbackKey });
第一行建立唯一来源;查询和失效复用同一个值,避免两个地方手写后漂移。
迁移规则:凡是看到 invalidateQueries,都要反向找到创建该数据的 useQuery,比较完整 key 或前缀是否能够匹配。用户详情可使用 ["user", userId],用户列表可使用 ["users", filters];不要只用裸 id,因为不同业务实体可能碰巧拥有相同 id。
自检方法:打开 React Query Devtools,提交反馈前后观察 ["finding-feedback", findingId] 是否变为 stale 并重新 fetching;也可 mock queryFn,断言成功 mutation 后调用次数增加。
建议改进:?? null 没有捕获 Promise 拒绝
trpcClient...query() 返回 Promise。只要请求函数正常被调用,左侧就是一个 Promise 对象,因此 Promise ?? null 永远选左侧。网络错误发生在 Promise 之后的 rejected 状态,不会经过这个空值运算符。
执行顺序是:
调用 query()
→ 立即得到 Promise 对象
→ `?? null` 判断 Promise 不是 null
→ 稍后网络失败,Promise reject
→ 之前的 `?? null` 已经结束,无法处理错误
要把错误转换成 null,需要在异步边界使用 try/catch 或 .catch(() => null)。不过 React Query 默认会捕获 rejected Promise 并记录 isError,所以页面通常不会直接崩溃;这里的主要问题是没有实现题目要求的“失败收敛为 null”语义。
第2题:提交反馈并精确失效缓存 — 5/10
一句话结论:提交实现与标准行为一致,但你明确写了“这里是我抄的”,因此只计算可运行结果,不把它记录为独立掌握。
考生核心代码:
mutationFn: (outcome: "accepted" | "rejected" | "ignored") =>
trpcClient.findings.addFeedback.mutate({ findingId, runId, outcome }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["finding-feedback", findingId] });
queryClient.invalidateQueries({ queryKey: ["findings"] });
options?.onSuccess?.();
},
疑问一:outcome 类型应该从 Schema 导出吗?
直接答案:如果这个枚举被多个层共同使用,抽成共享类型或共享 Zod Schema 会更稳;但当前标准答案写显式联合类型并不是功能错误。
这里有三种层级:
- tRPC router 的输入 Schema 在服务端校验运行时数据;
AppRouter把输入输出类型传给trpcClient,前端调用mutate时已经能获得类型检查;- mutationFn 参数需要描述按钮允许提交的三个值,显式联合类型能完成这件事,但存在重复。
更可复用的前端写法可以是:
type FeedbackOutcome = FeedbackEntry["outcome"];
mutationFn: (outcome: FeedbackOutcome) =>
trpcClient.findings.addFeedback.mutate({ findingId, runId, outcome });
如果需要让客户端、服务端和测试共同复用,则应把 feedbackOutcomeSchema 放入共享 Schema 包,再用 z.infer 得到类型。不要从服务端 router 文件反向导入到浏览器代码,那会破坏前后端边界。
如何确认:把鼠标放在 trpcClient.findings.addFeedback.mutate 的参数上,查看 IDE 是否已经显示 { findingId; runId; outcome; note? }。类型能正确出现,说明 tRPC 推导链仍在工作。
疑问二:finding-feedback 和 findings 是哪里来的?
它们不是 React Query 内置常量,而是项目开发者定义的缓存命名空间。
["finding-feedback", findingId]:定位某条 Finding 的反馈;["findings"]:约定为 Findings 集合的前缀,理论上可同时匹配["findings"]、["findings", filters]等查询。
注意:只有真正使用这些 key 的 React Query 查询才会被刷新。当前 Rule 详情页的 Findings 是通过直接 tRPC Promise 写入本地 state,并不使用 ["findings"] 缓存;所以第二次失效对该页面未必有作用。options.onSuccess 或显式重新请求才是通知这类本地 state 页面的方式。这是现有标准实现的设计局限,不对你额外扣分。
疑问三:失败时只 console.error 合适吗?
直接答案:作为开发期诊断可以,但作为完整产品反馈不够。
本 Hook 同时把 mutation 的 error 转成字符串返回,因此理想流程是:Hook 保留错误状态,组件渲染 role="alert" 或触发项目 toast,监控系统记录异常。当前 FeedbackButtons 没有消费 error,所以用户只看到按钮不更新,不知道失败原因。标准答案也有这项产品体验欠缺,本题不因此扣分。
第3题:隔离行内按钮事件并防止重复提交 — 10/10
一句话结论:三个回调均正确;你已经掌握了“先阻止冒泡,再判断 pending,再提交对应值”的顺序。
const handleAccept = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
if (!isPending) submit("accepted");
}, [submit, isPending]);
事件从按钮开始,默认会继续向父元素传播。stopPropagation() 放在 pending 判断之前很重要:即使请求进行中不再提交,点击也不能穿透到父行。
疑问:不写 useCallback 会怎么样?
直接答案:本题功能仍然正确,通常也不会产生可感知性能问题。
普通函数会在每次渲染时得到新引用;useCallback 在依赖不变时复用旧引用。这里回调直接交给原生 button,button 不会因为函数引用变化而单独执行昂贵逻辑,因此 useCallback 的实际收益很小。
它更有价值的场景是:
- 回调传给经过
React.memo的子组件; - 回调进入其他 Hook 的依赖数组;
- 第三方组件根据回调引用决定是否重新订阅。
迁移规则:先确认“稳定引用是否被下游观察”,再决定是否使用 useCallback。不要把它当成所有事件处理器的必选语法。
自检方法:临时去掉 useCallback,用 React DevTools Profiler 比较子组件渲染;如果没有 memo 子组件或订阅变化,行为通常完全一致。
第4题:把反馈按钮接到三个事件回调 — 8/10
一句话结论:按钮接线、当前态高亮和可访问名称正确;删除图标不影响提交,但属于超出题目范围的 UI 回退。
- 必须行为:三个
onClick与 accept/reject/ignore 一一对应,正确。 - 可访问性:按钮仍有文字和
aria-label,即使图标缺失,读屏名称仍存在,不扣可访问性分。 - 建议改进:不要因为 IDE 暂时报导入失败就删除已跟踪的 import 和 JSX;应先确认依赖安装、TypeScript Server 和路径解析。
本机验证显示 node_modules/lucide-react 存在,且仓库其他文件正常导入 Lucide。更可能的排查顺序是:重新安装依赖 → 重启 TypeScript Server → 查看真正的 typecheck 错误 → 再决定是否修改代码。
同类问题还出现在页面的返回按钮:ArrowLeft 被改成注释。它不属于本题目标,属于应在提交前恢复或解释的范围外改动。
第5题:按规则身份加载 Findings — 9/10
一句话结论:Effect 的依赖、loading 生命周期和 { ruleId: id } payload 都正确;仅有类型断言过强。
useEffect(() => {
setFindingsLoading(true);
trpcClient.findings.listByRuleId.query({ ruleId: id })
.then((result) => setFindings(result as unknown as Finding[]))
.finally(() => setFindingsLoading(false));
}, [id]);
as unknown as Finding[] 的含义不是“验证 result 是 Finding[]”,而是先主动丢掉原类型,再强行声明目标类型。它会隐藏真实接口变化。
迁移规则:如果两个类型兼容,优先不写断言;如果不兼容,先核对 tRPC 输出类型和前端 Finding 是否代表同一领域对象。只有明确知道运行时结构且暂时无法修正上游时才使用断言,并留下原因。
第6题:守住规则开关的 mutation 边界 — 10/10
一句话结论:这次早返回使用正确,修复了历史上“失败分支继续提交”的薄弱点。
if (!rule) return;
updateRule({
resource: ResourceName.rules,
id,
values: { enabled: !rule.enabled },
});
运行过程是:先确认生成 payload 所需的 rule 已存在,再读取 rule.enabled,创建只包含更新字段的新对象,最后调用 mutation。没有修改原 rule,也不会在数据尚未加载时发请求。
这证明“前置条件守卫”已经掌握;但本题没有复测共享工厂生成完整表单对象,因此 Anatomy 创建表单中的领域对象边界仍需以后单独验证。
第7题:补齐 Findings 的异步三态骨架 — 10/10
一句话结论:三态优先级完全正确,LoadingState 与原来的 Loader 节点行为等价,因此不按标准答案的具体 JSX 扣分。
findingsLoading ? <LoadingState />
: findings.length <= 0 ? <EmptyState />
: <div>{/* data */}</div>
因为数组长度不可能小于 0,<= 0 与 === 0 在这里等价。EmptyState 使用默认文案与原翻译文案不同,按 i18n 规则不扣分。
异步分支的关键不是组件名,而是互斥顺序:请求进行时,即使数组暂时为空也必须显示 loading;只有请求结束且数组为空才显示 empty;剩余情况显示 data。
第8题:组合可展开行与反馈子组件 — 4/10
一句话结论:反馈组件拿到了正确身份,但父行没有点击处理器,所以整行永远无法展开,isOpen 也不会因用户操作改变。
考生代码:
<div key={f.id} className="...cursor-pointer...">
{/* ... */}
<FeedbackButtons
findingId={String(f?.id) ?? ""}
runId={f?.runId ?? ""}
/>
</div>
必须修复:交互链少了起点
事件隔离不是只写 stopPropagation 就完成了,它要求父子两层各自承担职责:父行接收普通点击并展开,子按钮阻止自己的点击继续到父行。
运行过程:
当前代码点击行
→ 行没有 onClick
→ handleToggleExpand 从未执行
→ expanded 保持原值
→ isOpen 始终不能由用户切换
当前代码点击反馈按钮
→ 第3题 stopPropagation 正常执行
→ 但父行本来也没有处理器
→ 无法证明完整的父子事件边界已经接通
最小修复:
<div
key={f.id}
onClick={() => handleToggleExpand(f.id)}
>
<FeedbackButtons findingId={String(f.id)} runId={f.runId ?? ""} />
</div>
第一处建立父行行为;第二处依赖第3题在按钮内部停止冒泡。两部分共同构成复合交互。
String(f?.id) ?? "" 还有一个机械问题:String(...) 永远返回字符串,即使输入 undefined 也会得到字符串 "undefined",所以右侧 ?? "" 永远不会执行。并且 f 来自 findings.map,本身不需要可选链。目标写法是 String(f.id)。
迁移规则:遇到“可点击卡片/行内部还有按钮或链接”,同时检查两点:父容器是否有明确主行为;内部交互是否用 stopPropagation 或父级 closest() 守卫排除。只完成一层不能算完整。
自检方法:依次点击行空白处、反馈按钮、行内链接;预期只有空白处切换展开,反馈只提交,链接只导航。目标 lint 也会通过 handleToggleExpand 是否未使用暴露这类断链。
多文件数据流与接口一致性
RuleDetailPageContent → FeedbackButtons → useFindingFeedback 的 Props 和 mutation payload 已接通。三个按钮提交的 outcome 正确,findingId/runId 也能传入 Hook。
存在两个跨文件断点:
- Hook 的查询使用
[findingId],成功 mutation 却失效["finding-feedback", findingId],反馈状态无法按预期刷新; - 页面声明了
handleToggleExpand,但 Finding 行没有消费它,展开状态链在 JSX 处断开。
疑问集中解答
1. currentOutcome 是 any 吗?
不应默认是 any。trpcClient 由 AppRouter 提供类型,getFeedbackByFinding 的输出会传给 useQuery,再传到 data 和 feedback。如果 IDE 实际显示 any,应沿 trpcClient → AppRouter → findings router 返回值 检查哪一层丢失类型,而不是仅给 currentOutcome 补断言。
当前文件中的 FeedbackEntry 只是一个未被 Hook 使用的本地类型,不会自动约束 query 输出。需要在前端明确收口时,可给 useQuery<FeedbackEntry | null> 泛型,或者从共享 Schema 导出输出类型。
2. 为什么 key 不集中定义?
小项目可以就地写数组;一旦同一 key 同时用于查询、失效、预取和测试,就适合建立 key factory:
const feedbackKeys = {
all: ["finding-feedback"] as const,
detail: (id: string) => [...feedbackKeys.all, id] as const,
};
这样查询和失效都调用 feedbackKeys.detail(findingId),避免本次出现的漂移。
3. 为什么标准答案也可能不够完整?
标准答案是评分基准,不代表架构上不可改进。比如本题的 console.error 没有用户反馈,["findings"] 也不会自动刷新直接写入本地 state 的列表。阅卷按题目验收行为判断等价实现,同时会把标准实现的局限单独说明,不因你没有超出题目修复这些设计债而扣分。
验证结果
| 检查 | 结果 | 说明 |
|---|---|---|
bun run check-types | 失败 | 1 个目标错误:handleToggleExpand 声明但未使用,对应第8题展开链断开 |
| 目标文件 oxlint | 失败 | 1 error、2 warnings:return 前多余空行、未使用 handleToggleExpand、String(...) ?? "" 左侧恒不为空 |
| 相关前端测试 | 无 | 仓库没有这三个 Hook/组件/页面的前端测试;发现的 findings.test.ts 属于 tRPC router,按考试边界未运行 |
git diff --check | 失败 | 第2题“这里是我抄的”注释存在尾随空格 |
| 依赖确认 | 通过 | lucide-react 已安装并可从当前 app 的 node_modules 解析 |
| 调试与疑问扫描 | 需清理 | 保留多条 TODO/抄写说明、注释图标和 console.error;后者来自标准答案,不作为个人调试遗留扣分 |
致命问题
- 查询 key 和 mutation 失效 key 不一致,反馈提交后当前高亮可能保持旧值。
- Finding 行缺少
onClick,展开/收起功能完全不可用。
三大改进方向
- 把 queryKey 当成缓存地址:查询、失效、预取和测试统一从 key factory 获取。
- 检查完整交互链,而不是单个处理器:父行主行为和子按钮事件隔离必须同时存在。
- 不用断言和删除 import 掩盖工具报错;先从类型源、依赖解析和真实检查结果定位原因。
下一次训练建议
下一场不再采用挖空填空。建议给出一个完整 Issue:“反馈提交后高亮不更新、点击反馈会影响行展开”,由考生自主定位文件、提出方案、实现、补测试并提交验证说明。专项填空只在同一基础概念再次失败时短暂使用。