Skip to content

Commit 125eec8

Browse files
committed
feat(components): 片段行补上移动到组入口
批 7 的规格义务漏项(控制方取证链已闭合): - 规格 docs/superpowers/specs/2026-09-11-frontend-redesign-design.md:738 逐字 「| 46 | `update_fragment_group` | **补 UI(本批)** |」;计划 :2299 C11 给了落点 `components/FeedFragmentList.tsx` 与判据「① 菜单存在 ② invoke 载荷正确」;计划 :532 A-2 点名该条为「命令本身接线」。T20 的 D 组交付六条 ⇒ 恰在本条上复现了计划 :2304 预警的掉落。 - 本提交兑现「接线」那一半:`app/src` 生产侧调用点 0 → 1(探针 = batch7UiWiring 第 ⑦ 条)。 载荷键名(计划勘误第 13 例):Rust 真身 commands_fragments.rs:141 的参数是 `fragment_id` ⇒ Tauri 2 走 camelCase ⇒ JS 侧必须是 `{ fragmentId, groupId }`。计划 C11 那格写的 `{ id, groupId }` 是错的(同文件先例:`delete_fragment` 传 `fragmentId`)。 交付: - 新建 components/FragmentGroupAction.tsx(125 行):片段行「📁 移动到组」入口 —— Button 原语真按钮(鼠标/键盘都到得了),就地铺开笔记容器组;选中 ⇒ invoke("update_fragment_group", { fragmentId, groupId });「移出组」⇒ groupId: null (Rust Option<i64> 的 None 语义);失败走父层既有 setErr(可见 StatusLine,不空 catch); REQ-316 的 autoCleanedGroups 照 runPromote / runDelete 同形态上抛。组清单复用父层 loadGroupsIfNeeded(不重建第二套加载器)。 - components/FeedFragmentList.tsx 297 → 300(+3,用满授权预算):import + 2 行 JSX; 头部注释的三出口改四出口(行数不变)。 - 测试:FeedFragmentList.test.tsx 5 → 9 用例(桩里补 update_fragment_group); batch7UiWiring.test.tsx 的 COMMANDS 补入第 7 条 ⇒ 19 → 20 用例(源码探针 PROD ≥1)。 冻结键全部持平(probe-frozen.mjs 实测):FeedFragmentList nativeButton 6、边框 5、越界圆角 0、 阴影 0、弱化灰 1、字号越界 8、三红 1、Surface 标签 1;新件六棘轮全 0、原生 button 0。 门禁(串行):line-limits --full exit 0(0 / 121 / 121)· docs-check exit 0(扫描 281 / 检查 181 + ✅ docs-check 通过)· check-command-registry exit 0(定义 311 / 注册 311 / 重复 0)· tsc --noEmit exit 0 · 守卫族 12 件 122 用例全绿(textRatchet / surfaceRatchet / nativeButton.ratchet / buttonMigration / emptyStateRatchet / loadingRatchet / statusLineRatchet / style-contract / dialogMigration.e / surfaceTagRegistry / zIndex.guard / confirmMigration)。
1 parent d0ada78 commit 125eec8

4 files changed

Lines changed: 228 additions & 4 deletions

File tree

‎app/src/components/FeedFragmentList.test.tsx‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ beforeEach(() => {
5656
case "delete_fragment":
5757
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
5858
return { deleted: true, autoCleanedGroups: [] };
59+
// 批 7 C11:碎片归组(桩把 groupId 真的挪过去 —— 重载后行内状态才对得上)
60+
case "update_fragment_group":
61+
dbFragments = dbFragments.map((f) => (f.id === args.fragmentId ? { ...f, groupId: args.groupId as number | null } : f));
62+
return { moved: true, autoCleanedGroups: [] };
5963
case "resolve_fragment_image":
6064
return null;
6165
default:
@@ -201,3 +205,93 @@ describe("FeedFragmentList 收件箱状态机", () => {
201205
expect(count()).toBe(1);
202206
});
203207
});
208+
209+
/**
210+
* 批 7 C11(规格 §9 第 46 条 `update_fragment_group`):片段行的归组入口。
211+
*
212+
* @ai-context 与 T20-D 的分工:`batch7UiWiring.test.tsx` 第 ⑦ 条管「生产侧恰有调用点」,
213+
* 本组管「点得到 + 载荷逐字 + 正/负控 + 失败不静默」。载荷键名按 Rust 真身走 camelCase
214+
* (`fragment_id` ⇒ `fragmentId`;计划 C11 那格写的 `id` 是勘误)。
215+
*/
216+
describe("批 7 C11 · 片段归组入口(update_fragment_group)", () => {
217+
it("入口是常规点击可达的真按钮;选中组 ⇒ 载荷逐字(负控:展开不发 · 另一行的 id 不出现)", async () => {
218+
const onChanged = vi.fn();
219+
render(<FeedFragmentList onChanged={onChanged} onPromoted={vi.fn()} />);
220+
await screen.findByTestId("fragment-card-2");
221+
222+
// 正控①:入口是 Button 原语(真 <button> ⇒ 鼠标与键盘都到得了)
223+
const entry = screen.getByTestId("fragment-move-group-2");
224+
expect(entry.tagName, "归组入口必须是可点击的真按钮").toBe("BUTTON");
225+
fireEvent.click(entry);
226+
const option = await screen.findByTestId("fragment-move-group-2-9");
227+
// 负控①:只展开清单不发命令
228+
expect(invokeMock.mock.calls.map((c) => c[0]), "展开清单本身不得发命令").not.toContain("update_fragment_group");
229+
230+
// 正控②:选中组 9 ⇒ 载荷逐字(fragmentId = Rust 侧 fragment_id 的 camelCase)
231+
fireEvent.click(option);
232+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 9 }));
233+
// 负控②:载荷里的 fragmentId 只能是点的那一行
234+
expect(invokeMock).not.toHaveBeenCalledWith("update_fragment_group", { fragmentId: 1, groupId: 9 });
235+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
236+
});
237+
238+
it("移出组:已归组的行有该出口且传 groupId: null;未归组的行**没有**该出口", async () => {
239+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
240+
await screen.findByTestId("fragment-card-1");
241+
fireEvent.click(screen.getByTestId("fragment-move-group-1"));
242+
const none = await screen.findByTestId("fragment-move-group-1-none");
243+
244+
// 负控:未归组的行(fragment 2)不出现「移出组」
245+
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
246+
await screen.findByTestId("fragment-move-group-2-9");
247+
expect(screen.queryByTestId("fragment-move-group-2-none"), "未归组的行不该有「移出组」").toBeNull();
248+
249+
// 正控:移出组 ⇒ groupId: null(Rust Option<i64> 的 None 语义)
250+
fireEvent.click(none);
251+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 1, groupId: null }));
252+
});
253+
254+
it("失败路径不静默:命令 reject ⇒ 收件箱错误行可见,且带后端原因(父层既有 setErr 形态)", async () => {
255+
invokeMock.mockImplementation(async (cmd: string) => {
256+
if (cmd === "list_fragments") return dbFragments;
257+
if (cmd === "list_note_groups") return [containerGroup];
258+
if (cmd === "resolve_fragment_image") return null;
259+
if (cmd === "update_fragment_group") throw new Error("feed 开关未开启");
260+
throw new Error(`unexpected command: ${cmd}`);
261+
});
262+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
263+
await screen.findByTestId("fragment-card-2");
264+
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
265+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
266+
await waitFor(() => expect(screen.getByTestId("inbox-error").textContent).toContain("移动到组失败"));
267+
expect(screen.getByTestId("inbox-error").textContent, "后端原因必须落到可见行").toContain("feed 开关未开启");
268+
});
269+
270+
it("REQ-316:源组被自动清理 ⇒ 上抛组标题;无清理(空数组)⇒ 不打扰", async () => {
271+
const stub = (cleaned: string[]) => async (cmd: string): Promise<unknown> => {
272+
if (cmd === "list_fragments") return dbFragments;
273+
if (cmd === "list_note_groups") return [containerGroup];
274+
if (cmd === "resolve_fragment_image") return null;
275+
if (cmd === "update_fragment_group") return { moved: true, autoCleanedGroups: cleaned };
276+
throw new Error(`unexpected command: ${cmd}`);
277+
};
278+
const onCleanNotice = vi.fn();
279+
invokeMock.mockImplementation(stub(["旧主题组"]));
280+
const { unmount } = render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} onCleanNotice={onCleanNotice} />);
281+
await screen.findByTestId("fragment-card-2");
282+
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
283+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
284+
await waitFor(() => expect(onCleanNotice).toHaveBeenCalledWith(["旧主题组"]));
285+
unmount();
286+
287+
// 负控:结果为空 ⇒ 零变化,不上抛(与 runPromote / runDelete 同向)
288+
const quiet = vi.fn();
289+
invokeMock.mockImplementation(stub([]));
290+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} onCleanNotice={quiet} />);
291+
await screen.findByTestId("fragment-card-2");
292+
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
293+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
294+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 9 }));
295+
expect(quiet, "无清理 ⇒ 不上抛").not.toHaveBeenCalled();
296+
});
297+
});

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
* FeedFragmentList — 收件箱碎片列表(v0.12.2 收件箱动线;自 feed 组碎片列表改造)。
33
*
44
* @ai-context: 二元论(用户裁决)——碎片=原料不是短笔记,收件箱只装碎片
5-
* (未归组笔记在「全部笔记」,两种实体两条动线)。碎片卡三出口:
5+
* (未归组笔记在「全部笔记」,两种实体两条动线)。碎片卡四出口:
66
* ✍ 升为笔记(轻确认:标题预填首句可改 + 归组下拉默认未归组 →
77
* promote_fragment_to_note 事务建笔记+删碎片 → 父层右侧自动打开)、
88
* ⚙ 升为闪卡(promote_fragment_to_card 幂等——已升级/单句无卡
9-
* 返回 0 不报错)、🗑 删除(二次确认)。
9+
* 返回 0 不报错)、🗑 删除(二次确认)、📁 移动到组(归组 / 移出组 —— 批 7 C11)。
1010
* @ai-context: 恒常视图——不再依赖 feed 组展开(开关只控制快速记录入口
1111
* 与后端准入);空态引导三种归宿(规划 §3)。
1212
*/
@@ -17,6 +17,7 @@ import type { Fragment, Note, NoteGroup } from "../types";
1717
import type { DeleteFragmentResult, PromoteNoteResult } from "../types/notes";
1818
import { fragmentPreview, promoteTitleFor } from "../utils/inbox";
1919
import { ConfirmDialog, StatusLine, Surface, Text } from "../ui/primitives";
20+
import FragmentGroupAction from "./FragmentGroupAction";
2021

2122
interface Props {
2223
/** 列宽(v0.15 全站自适应——父层 useColumnLayout 驱动;缺省 320=历史值) */
@@ -232,6 +233,8 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
232233
>
233234
🗑 删除
234235
</button>
236+
<FragmentGroupAction fragmentId={f.id} groupId={f.groupId} groups={containerGroups} onNeedGroups={loadGroupsIfNeeded}
237+
disabled={busy} onMoved={() => { void load().then(onChanged); }} onError={setErr} onCleanNotice={onCleanNotice} />
235238
<span style={{ fontSize: 9, color: "#d1d5db", marginLeft: "auto" }}>
236239
{new Date(f.createdAt * 1000).toLocaleDateString()}
237240
</span>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* FragmentGroupAction — 收件箱碎片行的「移动到组」入口(批 7 C11:规格 §9 第 46 条
3+
* `update_fragment_group` 的补 UI)。
4+
*
5+
* @ai-context: 后端 `update_fragment_group`(commands_fragments.rs)自 REQ-201 起在册、今天
6+
* 仍在注册表里,但生产侧一直没有调用方(规格 §9 第 46 条逐字「声称已接线但
7+
* 实际无调用方」)⇒ 本件把那半条「接线」兑现:入口 + 指哪打哪的 IPC 载荷。
8+
* @ai-context: 形态照同目录既有的零对话框纪律(FeedFragmentList 的升笔记轻确认)—— 点
9+
* 「移动到组」就地铺开组清单,选中即发命令;不新造菜单 / 浮层原语
10+
* (ui/primitives 下没有 Menu),也不占用原生 button 棘轮的配额(一律 Button 原语)。
11+
* @ai-context: 组清单由父层 loadGroupsIfNeeded() 惰性加载后以 props 传入 —— 本件不自建第二套
12+
* 加载器(同一次 list_note_groups 的同一份结果;两套加载器就是两套口径)。
13+
* `groupId: null` = 移出组,与 Rust 侧 `Option<i64>` 的 None 语义逐字对齐。
14+
* @ai-context: 三条出口都不静默:成功 ⇒ 收起 + 源组自动清理留痕(REQ-316 的 autoCleanedGroups,
15+
* 照 FeedFragmentList 的 runPromote 同一形态上抛);失败 ⇒ 交父层既有的 setErr
16+
* (落到 inbox-error 那条 StatusLine 上;空 catch 是安全红线)。
17+
* 边界:本件不做视觉决策 —— 边框 / 圆角 / 阴影 / 色值 / 字号一个字面量都没有,一律由 Button
18+
* 与 Text 原语的类承载。副作用:只在用户点击后发一次 IPC,不读 store、不写磁盘。
19+
*/
20+
import { useState } from "react";
21+
import { invoke } from "@tauri-apps/api/core";
22+
import type { NoteGroup } from "../types";
23+
// REQ-316(批 7):碎片移组返回契约(源空组清理留痕数据源)
24+
import type { MoveFragmentResult } from "../types/notes";
25+
import { Button, Text } from "../ui/primitives";
26+
27+
interface Props {
28+
fragmentId: number;
29+
/** 当前组 id(null=未归组 —— 此时不出现「移出组」出口,与后端 None 语义同向) */
30+
groupId: number | null;
31+
/** 可选组清单(父层惰性加载;null=尚未加载 —— 不把「没加载」谎报成「没有组」) */
32+
groups: NoteGroup[] | null;
33+
/** 展开时惰性拉组(父层 loadGroupsIfNeeded —— 每行都预拉会让 N 行列表发 N 次 IPC) */
34+
onNeedGroups: () => void;
35+
/** 父层动作在飞(与同行其余出口共用同一把 busy 门) */
36+
disabled: boolean;
37+
/** 移组成功后的父层刷新(重载收件箱 + 侧栏计数) */
38+
onMoved: () => void;
39+
/** 失败可见反馈(父层既有 setErr 形态:错误必须落到可见的 StatusLine) */
40+
onError: (msg: string) => void;
41+
/** REQ-316(批 7):源组因移走变空被自动清理 → 上抛组标题(父层 toast 留痕) */
42+
onCleanNotice?: (groupNames: string[]) => void;
43+
}
44+
45+
export default function FragmentGroupAction({
46+
fragmentId, groupId, groups, onNeedGroups, disabled, onMoved, onError, onCleanNotice,
47+
}: Props) {
48+
const [open, setOpen] = useState(false);
49+
const [busy, setBusy] = useState(false);
50+
// 组名只在清单到手后才敢说;未加载时**不**猜(groupId 非空却显示「未归组」就是撒谎)
51+
const groupName = (groups ?? []).find((g) => g.id === groupId)?.name;
52+
53+
/** 展开/收起;**首次展开才拉组** */
54+
const toggle = (): void => {
55+
if (!open) onNeedGroups();
56+
setOpen(!open);
57+
};
58+
59+
/** 选中一个目标(null=移出组);点当前组不产生伪写 */
60+
const move = async (target: number | null): Promise<void> => {
61+
if (busy || target === groupId) return;
62+
setBusy(true);
63+
try {
64+
const r = await invoke<MoveFragmentResult>("update_fragment_group", { fragmentId, groupId: target });
65+
setOpen(false);
66+
// REQ-316(批 7):碎片源组因移走变空 → 自动清理留痕(结果空=零变化)
67+
if (r.autoCleanedGroups.length > 0) onCleanNotice?.([...new Set(r.autoCleanedGroups)]);
68+
onMoved();
69+
} catch (e) {
70+
onError(`移动到组失败: ${e}`);
71+
} finally {
72+
setBusy(false);
73+
}
74+
};
75+
76+
return (
77+
<span style={{ display: "inline-flex", flexDirection: "column", gap: 4 }}>
78+
<Button
79+
size="sm"
80+
variant="secondary"
81+
disabled={disabled}
82+
testId={`fragment-move-group-${fragmentId}`}
83+
title="移动到组(碎片归组 / 移出组)"
84+
onClick={toggle}
85+
>
86+
📁 移动到组
87+
</Button>
88+
{open && (
89+
<span style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
90+
{groups !== null && (
91+
<Text size={2} tone="ink-3" testId={`fragment-move-group-${fragmentId}-current`}>
92+
{groupName === undefined ? "当前未归组" : `当前:${groupName}`}
93+
</Text>
94+
)}
95+
{(groups ?? []).map((g) => (
96+
<Button
97+
key={g.id}
98+
size="sm"
99+
variant={g.id === groupId ? "primary" : "ghost"}
100+
disabled={busy}
101+
testId={`fragment-move-group-${fragmentId}-${g.id}`}
102+
onClick={() => void move(g.id)}
103+
>
104+
{g.name}
105+
</Button>
106+
))}
107+
{groupId !== null && (
108+
<Button
109+
size="sm"
110+
variant="ghost"
111+
disabled={busy}
112+
testId={`fragment-move-group-${fragmentId}-none`}
113+
onClick={() => void move(null)}
114+
>
115+
移出组
116+
</Button>
117+
)}
118+
{groups !== null && groups.length === 0 && (
119+
<Text size={2} tone="ink-3">先在左侧建一个笔记组</Text>
120+
)}
121+
</span>
122+
)}
123+
</span>
124+
);
125+
}

‎app/src/components/batch7UiWiring.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @vitest-environment jsdom
22
/**
3-
* batch7UiWiring.test.tsx — 批 7 T20 的 6 条「补 UI」接线判据(§9 #34 / #37 / #39 / #40 / #42 / #47)。
3+
* batch7UiWiring.test.tsx — 批 7 T20 的 6 条「补 UI」接线判据(§9 #34 / #37 / #39 / #40 / #42 / #47)
4+
* + T23 补漏的第 7 条(§9 #46 `update_fragment_group` —— 规格那格写「补 UI(本批)」,T20 漏做)。
45
*
56
* @ai-context 每条命令两条独立判据(缺一不可):
67
* ① **行为**:入口存在 ⇒ 交互 ⇒ `invoke` 的命令名与载荷逐字正确(**只写按钮不调 `invoke`」会红**);
@@ -30,13 +31,14 @@ import SessionRefineSection from "./session-detail/SessionRefineSection";
3031
import KnowledgeSystemEditPanel from "./KnowledgeSystemEditPanel";
3132
import StaleSessionRecoveryBar from "./StaleSessionRecoveryBar";
3233

33-
/** 六条命令 → 落地件(源码探针与行为判据共用一份名单) */
34+
/** 七条命令 → 落地件(源码探针与行为判据共用一份名单;末条由 T23 补入) */
3435
const COMMANDS = [
3536
"analyze_session_command",
3637
"delete_session_images_all",
3738
"finish_session",
3839
"get_decision",
3940
"refine_session",
41+
"update_fragment_group",
4042
"update_knowledge_system",
4143
] as const;
4244

0 commit comments

Comments
 (0)