Skip to content

Commit f5731e1

Browse files
committed
fix(notes): 弹层串组/升笔记搜索态竞态等审查修复 6 项(v0.12.2 审查批次)
- MED ⓘ 弹层跨组 state 串组:key={group.id} 重置改判/明细/结算表单态(A 组选择不再误用进 B 组)+ GroupSidebar 组件测试 2 项 - MED onPromoted 同步清 keyword/tagFilter/groupFilter——原只 load(""),防抖 effect 会用旧搜索词覆盖列表(新笔记被搜索态淹没) - LOW 捕获/弹层变更后双 load 合并(统一由 refreshToken 驱动);升卡成功 setErr 重复清理;升笔记标题 maxLength=100(后端同口径) - LOW inbox.ts 截断按码点切(防切半代理对残字)+ 文档視口错字;NotesPage 行数豁免登记同步 328→334
1 parent fe8c919 commit f5731e1

7 files changed

Lines changed: 141 additions & 11 deletions

File tree

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ export default function FeedFragmentList({ onChanged, onPromoted }: Props) {
134134
setBusy(true);
135135
try {
136136
const n = await invoke<number>("promote_fragment_to_card", { fragmentId: f.id });
137-
setErr("");
138137
if (n > 0) {
138+
// 成功出卡:清错误 + 刷新收件箱(碎片保留——仍可升笔记)
139139
setErr("");
140140
await load();
141141
onChanged();
@@ -225,6 +225,7 @@ export default function FeedFragmentList({ onChanged, onPromoted }: Props) {
225225
onChange={(e) => setPromote({ ...promote, title: e.target.value })}
226226
placeholder="笔记标题…"
227227
autoFocus
228+
maxLength={100} // 与后端 TITLE_MAX_CHARS 同口径(防超长 payload)
228229
style={{ width: "100%", fontSize: 12, padding: "4px 6px", border: "1px solid #e5e7eb", borderRadius: 4, boxSizing: "border-box", marginBottom: 4 }}
229230
/>
230231
<select
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* GroupSidebar.test.tsx — 组筛选侧栏 + ⓘ 弹层交互测试(v0.12.2 审查修复)。
4+
*
5+
* @ai-context: 覆盖串组场景——切换 ⓘ 弹层目标组时表单态必须重置(key=group.id
6+
* 修复):组 A 改了判类(未确认)→ 点组 B ⓘ → 弹层显示 B 且
7+
* 判类下拉回到 B.kind(防把 A 的选择误用到 B——路径: 改判误操作)。
8+
* invoke 全 mock(list_note_groups/count_due_cards/list_fragments/
9+
* get_feature_flags/week_contract_status)。
10+
*/
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
13+
import type { NoteGroup } from "../types";
14+
15+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
16+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
17+
18+
import GroupSidebar from "./GroupSidebar";
19+
20+
const groupA: NoteGroup = {
21+
id: 1, name: "化妆课 A", terrain: "container", kind: "course", domainTag: null,
22+
source: "route", seriesKey: null, routeReason: null, routeOverridden: 0,
23+
noteCount: 3, createdAt: 0, updatedAt: 0,
24+
};
25+
const groupB: NoteGroup = {
26+
id: 2, name: "手账 B", terrain: "container", kind: "standalone", domainTag: null,
27+
source: "route", seriesKey: null, routeReason: null, routeOverridden: 0,
28+
noteCount: 1, createdAt: 0, updatedAt: 0,
29+
};
30+
31+
beforeEach(() => {
32+
invokeMock.mockReset();
33+
invokeMock.mockImplementation(async (cmd: string) => {
34+
switch (cmd) {
35+
case "list_note_groups": return [groupA, groupB];
36+
case "count_due_cards": return 0;
37+
case "list_fragments": return [];
38+
case "get_feature_flags": return { feedCapture: true };
39+
case "week_contract_status":
40+
return { contract: null, weekStart: 0, actualDays: 0, actualCards: 0, minimalDayMet: false };
41+
case "override_group_route": return true;
42+
default:
43+
throw new Error(`unexpected: ${cmd}`);
44+
}
45+
});
46+
});
47+
48+
afterEach(() => cleanup());
49+
50+
function renderSidebar() {
51+
return render(
52+
<GroupSidebar
53+
groupFilter={null}
54+
onGroupFilterChange={vi.fn()}
55+
onChanged={vi.fn()}
56+
onOpenReview={vi.fn()}
57+
selectedNoteId={null}
58+
onOpenInbox={vi.fn()}
59+
inboxActive={false}
60+
refreshToken={0}
61+
/>,
62+
);
63+
}
64+
65+
describe("GroupSidebar ⓘ 弹层", () => {
66+
it("组行单击仅过滤(不展开)——组行与 ⓘ 为独立动作", async () => {
67+
const onGroupFilterChange = vi.fn();
68+
render(
69+
<GroupSidebar
70+
groupFilter={null}
71+
onGroupFilterChange={onGroupFilterChange}
72+
onChanged={vi.fn()}
73+
onOpenReview={vi.fn()}
74+
selectedNoteId={null}
75+
onOpenInbox={vi.fn()}
76+
inboxActive={false}
77+
refreshToken={0}
78+
/>,
79+
);
80+
await screen.findByTestId("group-row-1");
81+
// 单击组行 = 仅设置过滤(无展开动作——弹层不打开)
82+
fireEvent.click(screen.getByTestId("group-row-1"));
83+
expect(onGroupFilterChange).toHaveBeenCalledWith(1);
84+
expect(screen.queryByTestId("route-popover")).toBeNull();
85+
// ⓘ 才是弹层入口
86+
fireEvent.click(screen.getByTestId("group-info-1"));
87+
expect(await screen.findByTestId("route-popover")).toBeTruthy();
88+
});
89+
90+
it("切换组时弹层表单态重置(A 的选择不串进 B)", async () => {
91+
renderSidebar();
92+
// 打开组 A(course)弹层,改判类为 topic(未确认)
93+
fireEvent.click(await screen.findByTestId("group-info-1"));
94+
const kindA = (await screen.findByTestId("override-kind")) as HTMLSelectElement;
95+
expect(kindA.value).toBe("course");
96+
fireEvent.change(kindA, { target: { value: "topic" } });
97+
98+
// 切到组 B(standalone)——key 重建:表单态回到 B.kind
99+
fireEvent.click(screen.getByTestId("group-info-2"));
100+
const kindB = (await screen.findByTestId("override-kind")) as HTMLSelectElement;
101+
await waitFor(() => expect(kindB.value).toBe("standalone"));
102+
// 明细折叠态与结算计划同样重置
103+
expect(screen.queryByTestId("route-details")).toBeNull();
104+
});
105+
});

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ export default function GroupSidebar({
115115
await invoke("capture_fragment", { text, imageB64, source });
116116
setFragText("");
117117
setStatus("");
118-
await load(); // 可能新建 feed 主题组——刷新组列表/计数
118+
// 刷新统一由 onChanged(refreshToken)驱动——本组件 load 与 NotesPage
119+
// 列表重载各执行一次(审查修复:原显式 load + onChanged 会双跑全套请求)
119120
onChanged();
120121
} catch (e) {
121122
setStatus(`碎片捕获失败: ${e}`);
@@ -251,13 +252,16 @@ export default function GroupSidebar({
251252

252253
{status && <p style={{ padding: 8, fontSize: 12, color: "#dc2626" }}>{status}</p>}
253254

254-
{/* ⓘ 弹层(受控单开;变更后刷新) */}
255+
{/* ⓘ 弹层(受控单开;key=group.id——切组重置内部表单态,
256+
防 A 组的改判/结算选择串进 B 组——审查修复) */}
255257
{popover && (
256258
<RouteInfoPopover
259+
key={popover.group.id}
257260
group={popover.group}
258261
anchor={popover.anchor}
259262
onClose={() => setPopover(null)}
260-
onChanged={() => { void load(); onChanged(); }}
263+
// via onChanged(refreshToken)驱动本组件 load——不再显式双跑
264+
onChanged={onChanged}
261265
onOpenReview={(gid, name) => { setPopover(null); onOpenReview(gid, name); }}
262266
selectedNoteId={selectedNoteId}
263267
/>

‎app/src/pages/NotesPage.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,9 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
244244
onChanged={refreshAll}
245245
onOpenReview={(groupId, name) => setReview({ groupId, name })}
246246
selectedNoteId={selected?.id ?? null}
247-
onOpenInbox={() => setView("inbox")}
247+
// 收件箱=全量碎片视图,与组过滤无关——清组过滤消除"组行高亮 + 收件箱"
248+
// 并存矛盾(审查修复)
249+
onOpenInbox={() => { setGroupFilter(null); setView("inbox"); }}
248250
inboxActive={view === "inbox"}
249251
refreshToken={refreshToken}
250252
/>
@@ -257,7 +259,11 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
257259
// 右侧自动打开新笔记(闭环可见);碎片已从收件箱移除(列表已刷新)
258260
setSelected(note);
259261
setEditing(false);
260-
// 新笔记(可能已归组)回到全部笔记列表——确保「全部笔记」可见
262+
// 搜索/标签态同步清空(审查修复:原只 load("") 不更新 keyword/tagFilter,
263+
// 防抖 effect 会用旧搜索词重新覆盖列表——新笔记在「全部笔记」可见)
264+
setKeyword("");
265+
setTagFilter(null);
266+
setGroupFilter(null);
261267
void load("", null, sortMode);
262268
}}
263269
/>

‎app/src/utils/inbox.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
* 预填规则独立成纯函数可单测(空值/超长/无标点边界)。
77
*/
88

9+
/** Unicode 安全截断(按码点切——防 slice 按 UTF-16 单元切半代理对的 � 残字) */
10+
function sliceByChars(s: string, max: number): string {
11+
return Array.from(s).slice(0, max).join("");
12+
}
13+
914
/** 升笔记标题预填(预填首句;超长截断防标题溢出——TITLE_MAX_CHARS=100 前端上限 50 保守) */
1015
export function promoteTitleFor(text: string): string {
1116
const trimmed = text.trim();
@@ -18,12 +23,12 @@ export function promoteTitleFor(text: string): string {
1823
const base = first ?? trimmed;
1924
// 去掉句首装饰(前缀符号/列表标记)保标题干净
2025
const clean = base.replace(/^[\s#*\-•·]+/, "");
21-
return (clean || "未命名笔记").slice(0, 50);
26+
return sliceByChars(clean || "未命名笔记", 50);
2227
}
2328

2429
/** 碎片正文预览(收件箱卡内截断显示;超限补省略号) */
2530
export function fragmentPreview(text: string, max = 300): string {
2631
const t = text.trim();
27-
if (t.length <= max) return t;
28-
return `${t.slice(0, max)}…`;
32+
if (Array.from(t).length <= max) return t;
33+
return `${sliceByChars(t, max)}…`;
2934
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
| app/src/components/SessionDetailPanel.tsx | 371 | 会话详情面板:质量报告/段列表/OCR 概览/操作区单一面板完整交互流内聚(前端审查登记) | 若再增长:质量报告区拆至 SessionQualityReport.tsx |
6767
| app/src/components/ProfileDetector.tsx | 330 | 档案检测组件:投票/确认流/记忆偏好 UI + v0.11.5 Task 5 冲突提示内聚(实测 2026-08-22) | 若再增长:确认流拆至 ProfileConfirmFlow.tsx |
6868
| app/src/components/NoteEditView.tsx | —— | v0.12.2 行数修正:实测 299 行(登记值 315 过期),本版新增 autoFocus 一行后仍 300 行以内——登记移除,见"已拆分/登记移除记录" | —— |
69-
| app/src/pages/NotesPage.tsx | 328 | v0.12.2 三栏编排(GroupSidebar/FeedFragmentList/NoteListView 插槽 + 收件箱动线 onPromoted 接线 + 新建零对话框)——编排层内聚(数据获取/选中态/快捷键/辅助面板插槽),组件已全部下沉拆件 | 若再增长:键盘监听与收件箱动线回调拆至 useNotesPageShortcuts.ts / useInboxFlow.ts |
69+
| app/src/pages/NotesPage.tsx | 334 | v0.12.2 三栏编排(GroupSidebar/FeedFragmentList/NoteListView 插槽 + 收件箱动线 onPromoted 接线 + 新建零对话框)——编排层内聚(数据获取/选中态/快捷键/辅助面板插槽),组件已全部下沉拆件;2026-08-23 审查修复(onPromoted 同步清搜索态/onOpenInbox 清组过滤)+6 行 | 若再增长:键盘监听与收件箱动线回调拆至 useNotesPageShortcuts.ts / useInboxFlow.ts |
7070
| app/src/components/AiProviderSettings.tsx | 329 | v0.11.6 M1 code-review 修复(2026-08-22):删除/清钥加 window.confirm、window.prompt 改卡片内联 password 输入(+2 state + 内联表单)、模型列表 input 改 textarea、fallbackOrder 透传 initial、run 置"处理中"反馈、预设双源 presetOptions 后端拉取——修复净增约 13 行越线(实测 329,含 4 行豁免头注释) | 若再增长:内联密钥表单拆至 AiProviderKeyInput.tsx |
7171

7272
> 前端 **拆分中**(Task #9 笔记域修复进行中,暂不登记行数):`app/src/types.ts`——待前端拆分完成后以实测行数重新评估。

‎docs/versions/v0.12.2.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,19 @@
146146

147147
### 与规划的主要差异(诚实记录)
148148

149-
- ⓘ 弹层定位用視口 fixed 锚定(最初设计为侧栏内 absolute;fixed 简化背板关闭逻辑,视觉等价)。
149+
- ⓘ 弹层定位用视口 fixed 锚定(最初设计为侧栏内 absolute;fixed 简化背板关闭逻辑,视觉等价)。
150150
- `noteTree.ts` 被三栏取代后删除(含单测)——v0.11.5 树形合并是"展开组必看笔记"的过渡形态,三栏下组行只过滤、列表常驻,树无消费方(YAGNI);如后续需要"组内速览"再以独立视图恢复。
151151
- 收件箱碎片卡未逐个显示所属组名(Fragment 契约不含 name;补计需要额外 join——YAGNI,升笔记/升卡时组选择已可见)。
152152

153+
### M4 · 新增代码七维审查与即修(2026-08-23 交付后审查)
154+
155+
- **HIGH**:`promote_fragment_to_note` 原实现"伪事务"——with_conn 只给 `&Connection` 无法开事务,rusqlite 默认 autocommit 使建笔记/删碎片各自提交,④ 失败留"笔记已建/碎片未删"半态;改为直接锁 + `conn.transaction()`+`commit()`(与 versioned_save 既有模式一致)。
156+
- **MED**:ⓘ 弹层跨组 state 串组——组 A 改判/结算/明细选择(未确认)复用进组 B(key 缺失);修复 `key={group.id}` 重置表单态 + GroupSidebar 组件测试(切换组判类回到目标组 kind)。
157+
- **MED**:升笔记 onPromoted 只调 `load("")` 不同步清 `keyword/tagFilter`——防抖 effect 用旧搜索词重新覆盖列表(新笔记"被搜索态淹没");修复同步清空三过滤态。
158+
- **LOW**:捕获/弹层变更后双 load(显式 load + onChanged→refreshToken 再 load)→ 统一由 refreshToken 驱动;升卡成功 `setErr` 重复;升笔记标题输入补 `maxLength=100`(与后端 TITLE_MAX_CHARS 同口径);`inbox.ts` 截断改按码点切(防切半代理对残字);文档"視口"错字。
159+
- 观察项(登记不修):archived 碎片可升笔记(与 delete/update 旧命令行为一致);升卡成功静默(幂等语义,验收允许);编辑态+弹层同开时 ESC 双触发(边缘场景);db 层文件名沿用碎片名(同毫秒+4hex 后缀碰撞概率 1/65536,单笔记同目录冲突极低)。
160+
- 验证:cargo check/clippy 通过、promote 4 测全绿;tsc 零错误、vitest **83 通过**(新增 GroupSidebar 2 测:组行单击仅过滤 + 弹层串组重置)、build 通过。
161+
153162
## 验收标准
154163

155164
1. 侧栏组行单击=仅过滤(中部列表切换,无展开动作);⚠ 待确认组淡橙描边小字显示;ⓘ 弹层四区齐全,明细默认折叠。

0 commit comments

Comments
 (0)