Skip to content

Commit d7d589b

Browse files
committed
feat(kb): 挂体系弹层搜索列表与三类内联轻建(REQ-286)
1 parent 47ae218 commit d7d589b

4 files changed

Lines changed: 389 additions & 82 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* LinkEntityPicker — 挂体系目标选择器(REQ-286,v0.19.7)。
3+
*
4+
* @ai-context: 取代旧「Tab+第二下拉」:搜索输入即过滤(本地数据已在手);
5+
* 列表行点击=选中挂接目标(问题行同时充当"其下新建"的父锚点);
6+
* 输入非空即出现「+ 新建『xx』」操作行(Enter 同效,零命中新建
7+
* 语义)——建后由父层回填选中。纯受控展示,加载/创建/列表刷新
8+
* 由父层(NoteLinkToSystem)负责。
9+
* @ai-context: 边界如实声明——行列表键盘 ↑↓ 导航列入全局观察项(§2.9 约定,
10+
* 本组件提供 Esc 两段式:先清输入再请求关闭);列表为父子同显
11+
* 的树形缩进视图(父锚点可视化),非平铺下拉。
12+
*/
13+
import { useMemo, useState } from "react";
14+
15+
export interface LinkRow {
16+
id: number;
17+
label: string;
18+
/** 树形缩进深度(问题节点父子层级;概念/模型恒 0) */
19+
depth: number;
20+
}
21+
22+
interface Props {
23+
rows: LinkRow[];
24+
selectedId: number | null;
25+
placeholder: string;
26+
/** 新建动作文案前缀(「问题」「概念」「模型」) */
27+
kindLabel: string;
28+
/** 无选中锚点时的新建落点说明(如「体系根」) */
29+
rootAnchorLabel: string;
30+
onPick: (id: number) => void;
31+
/** 新建(name 非空;anchorId=选中的问题节点,None=体系根)。成功 resolve、失败 throw */
32+
onCreate: (name: string, anchorId: number | null) => Promise<void>;
33+
/** Esc 第二段(输入已空)请求关闭浮层 */
34+
onClose?: () => void;
35+
}
36+
37+
const rowBtn = (selected: boolean): React.CSSProperties => ({
38+
display: "block",
39+
width: "100%",
40+
textAlign: "left",
41+
fontSize: 12,
42+
padding: "3px 6px",
43+
borderRadius: 4,
44+
border: "none",
45+
cursor: "pointer",
46+
background: selected ? "#f0fdfa" : "transparent",
47+
color: selected ? "#0f766e" : "#374151",
48+
});
49+
50+
export default function LinkEntityPicker({
51+
rows, selectedId, placeholder, kindLabel, rootAnchorLabel,
52+
onPick, onCreate, onClose,
53+
}: Props) {
54+
const [query, setQuery] = useState("");
55+
const [busy, setBusy] = useState(false);
56+
const [err, setErr] = useState("");
57+
58+
const kw = query.trim().toLowerCase();
59+
const visible = useMemo(
60+
() => (kw ? rows.filter((r) => r.label.toLowerCase().includes(kw)) : rows),
61+
[rows, kw],
62+
);
63+
// 选中行对象(父锚点说明用);节点 tab 的选中行=挂接目标=新建父锚
64+
const selected = rows.find((r) => r.id === selectedId) ?? null;
65+
66+
const doCreate = async () => {
67+
const name = query.trim();
68+
if (!name || busy) return;
69+
setBusy(true);
70+
setErr("");
71+
try {
72+
await onCreate(name, selectedId ?? null);
73+
// 成功:清空搜索(父层已重载列表并回填选中——行高亮可见)
74+
setQuery("");
75+
} catch (e) {
76+
setErr(`新建失败: ${e}`);
77+
} finally {
78+
setBusy(false);
79+
}
80+
};
81+
82+
return (
83+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
84+
<input
85+
data-testid="note-link-search"
86+
value={query}
87+
onChange={(e) => { setQuery(e.target.value); setErr(""); }}
88+
onKeyDown={(e) => {
89+
if (e.key === "Enter") void doCreate();
90+
else if (e.key === "Escape") {
91+
if (query) setQuery("");
92+
else onClose?.();
93+
}
94+
}}
95+
placeholder={placeholder}
96+
style={{ fontSize: 12, padding: "4px 6px", border: "1px solid #d1d5db", borderRadius: 4 }}
97+
/>
98+
<div style={{ maxHeight: 200, overflowY: "auto", border: "1px solid #f3f4f6", borderRadius: 6, padding: 2 }}>
99+
{visible.length === 0 && (
100+
<div style={{ fontSize: 11, color: "#9ca3af", padding: "6px 4px" }}>
101+
{kw ? `没有匹配——可直接回车新建「${query.trim()}」` : "暂无内容"}
102+
</div>
103+
)}
104+
{visible.map((r) => (
105+
<button
106+
key={r.id}
107+
data-testid={`note-link-row-${r.id}`}
108+
onClick={() => onPick(r.id)}
109+
style={rowBtn(selectedId === r.id)}
110+
title={r.depth > 0 ? undefined : undefined}
111+
>
112+
<span style={{ display: "inline-block", width: r.depth * 12 }} />
113+
{selectedId === r.id ? "✓ " : ""}
114+
{r.label}
115+
</button>
116+
))}
117+
</div>
118+
{query.trim() && (
119+
<button
120+
data-testid="note-link-create"
121+
disabled={busy}
122+
onClick={() => void doCreate()}
123+
style={{
124+
fontSize: 12, padding: "4px 6px", borderRadius: 6, cursor: busy ? "default" : "pointer",
125+
border: "1px solid #4f46e5", background: "#eef2ff", color: "#3730a3",
126+
opacity: busy ? 0.6 : 1,
127+
}}
128+
>
129+
{busy ? "创建中…" : `+ 新建${kindLabel}「${query.trim()}」${selected ? `(于「${selected.label.slice(0, 12)}」下)` : `(${rootAnchorLabel})`}`}
130+
</button>
131+
)}
132+
{!query.trim() && selected && (
133+
<div style={{ fontSize: 10, color: "#6b7280" }}>
134+
已选「{selected.label.slice(0, 16)}」——输入新名称可在其下新建{kindLabel}
135+
</div>
136+
)}
137+
{err && <div style={{ fontSize: 11, color: "#dc2626" }}>{err}</div>}
138+
</div>
139+
);
140+
}

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

Lines changed: 122 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// @vitest-environment jsdom
22
/**
3-
* NoteLinkToSystem.test.tsx — 笔记「挂到体系」选择器(v0.13.7 触点②;v0.14 C3 增强)。
3+
* NoteLinkToSystem.test.tsx — 笔记「挂到体系」选择器(v0.13.7 触点②;v0.14 C3;
4+
* REQ-286 v0.19.7 搜索列表 + 三类内联轻建)。
45
*
5-
* @ai-context: 覆盖 C3 新增——挂接目标三选一(问题/概念/模型)、已挂态切换目标
6-
* (先撤旧链再建新链)、反查命令 list_links_by_target、旧版本仅 nodeId
7-
* 兼容显示。修复手工输 id 断点——用户零 id 知识:选体系→选目标→确认。
6+
* @ai-context: 覆盖——选体系→点行选中→link 零 id 契约;未选禁用/切体系重置;
7+
* 三实体挂接 conceptId/modelId;已挂态切换先撤旧链、取消挂接;
8+
* 旧数据兼容显示;REQ-286 行内轻建(问题挂根/挂选中节点下、
9+
* 概念、模型)即建即选→确认挂接。invoke 全 mock(AAA)。
810
*/
911
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1012
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -49,12 +51,18 @@ function baseMock() {
4951
beforeEach(() => { invokeMock.mockReset(); baseMock(); });
5052
afterEach(() => cleanup());
5153

52-
describe("NoteLinkToSystem v0.13.7 基础", () => {
53-
it("未挂接时显示「挂到体系」按钮;点开选体系选问题节点确认后调用 link(零 id)", async () => {
54-
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
55-
fireEvent.click(await screen.findByTestId("note-link-open"));
56-
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
57-
fireEvent.change(await screen.findByTestId("note-link-entity"), { target: { value: "11" } });
54+
/** 打开浮层并选体系 2(返回 confirm 按钮供后续断言) */
55+
async function openWithSystem() {
56+
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
57+
fireEvent.click(await screen.findByTestId("note-link-open"));
58+
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
59+
}
60+
61+
describe("NoteLinkToSystem v0.13.7 基础(REQ-286 列表选中语义)", () => {
62+
it("点行选中问题节点确认 → link 携带 nodeId(零 id 知识)", async () => {
63+
await openWithSystem();
64+
fireEvent.click(await screen.findByTestId("note-link-row-11"));
65+
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(false);
5866
fireEvent.click(screen.getByTestId("note-link-confirm"));
5967
await waitFor(() => {
6068
expect(invokeMock).toHaveBeenCalledWith("link_knowledge_target", {
@@ -64,24 +72,20 @@ describe("NoteLinkToSystem v0.13.7 基础", () => {
6472
});
6573

6674
it("未选目标时确认按钮禁用(不挂体系根)", async () => {
67-
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
68-
fireEvent.click(await screen.findByTestId("note-link-open"));
69-
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
70-
const entitySelect = (await screen.findByTestId("note-link-entity")) as HTMLSelectElement;
71-
expect(entitySelect.value).toBe("");
75+
await openWithSystem();
7276
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(true);
73-
fireEvent.change(entitySelect, { target: { value: "11" } });
74-
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(false);
7577
});
7678

77-
it("切换体系时重置目标选择(防实体残留串体系——后端报「引用实体不属于该体系」)", async () => {
78-
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
79-
fireEvent.click(await screen.findByTestId("note-link-open"));
80-
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
81-
fireEvent.change(await screen.findByTestId("note-link-entity"), { target: { value: "11" } });
79+
it("切换体系时重置目标选择(防实体残留串体系)", async () => {
80+
await openWithSystem();
81+
fireEvent.click(await screen.findByTestId("note-link-row-11"));
82+
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(false);
83+
// 切到体系 1(无实体)——目标重置、原行消失、确认禁用
8284
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "1" } });
83-
expect(((await screen.findByTestId("note-link-entity")) as HTMLSelectElement).value).toBe("");
84-
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(true);
85+
await waitFor(() => {
86+
expect(screen.queryByTestId("note-link-row-11")).toBeNull();
87+
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(true);
88+
});
8589
});
8690

8791
it("无体系时提示先创建体系(不显示确认按钮)", async () => {
@@ -106,11 +110,9 @@ describe("NoteLinkToSystem v0.14 C3 三实体挂接", () => {
106110
});
107111

108112
it("挂接概念:切「概念」tab 选概念 → link 带 conceptId", async () => {
109-
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
110-
fireEvent.click(await screen.findByTestId("note-link-open"));
111-
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
113+
await openWithSystem();
112114
fireEvent.click(screen.getByTestId("note-link-tab-concept"));
113-
fireEvent.change(await screen.findByTestId("note-link-entity"), { target: { value: "21" } });
115+
fireEvent.click(await screen.findByTestId("note-link-row-21"));
114116
fireEvent.click(screen.getByTestId("note-link-confirm"));
115117
await waitFor(() => {
116118
expect(invokeMock).toHaveBeenCalledWith("link_knowledge_target", {
@@ -120,11 +122,9 @@ describe("NoteLinkToSystem v0.14 C3 三实体挂接", () => {
120122
});
121123

122124
it("挂接模型:切「模型」tab 选模型 → link 带 modelId", async () => {
123-
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
124-
fireEvent.click(await screen.findByTestId("note-link-open"));
125-
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
125+
await openWithSystem();
126126
fireEvent.click(screen.getByTestId("note-link-tab-model"));
127-
fireEvent.change(await screen.findByTestId("note-link-entity"), { target: { value: "31" } });
127+
fireEvent.click(await screen.findByTestId("note-link-row-31"));
128128
fireEvent.click(screen.getByTestId("note-link-confirm"));
129129
await waitFor(() => {
130130
expect(invokeMock).toHaveBeenCalledWith("link_knowledge_target", {
@@ -148,7 +148,6 @@ describe("NoteLinkToSystem v0.14 C3 已挂态与切换", () => {
148148
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
149149
const btn = await screen.findByTestId("note-link-open");
150150
expect(btn.textContent).toContain("已挂 · 摄影");
151-
// 实体名异步加载(linked 到达后才按体系拉列表)——waitFor 等名称映射完成
152151
await waitFor(() => {
153152
expect(screen.getByTestId("note-link-linked-label").textContent).toContain("概念 · 白平衡");
154153
});
@@ -166,7 +165,6 @@ describe("NoteLinkToSystem v0.14 C3 已挂态与切换", () => {
166165
}
167166
});
168167
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
169-
// 实体名异步加载(linked 到达后才按体系拉列表)——waitFor 等名称映射完成
170168
await waitFor(() => {
171169
expect(screen.getByTestId("note-link-linked-label").textContent).toContain("问题 · 照片为什么发灰?");
172170
});
@@ -187,10 +185,8 @@ describe("NoteLinkToSystem v0.14 C3 已挂态与切换", () => {
187185
});
188186
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
189187
fireEvent.click(await screen.findByTestId("note-link-open"));
190-
// 已挂概念 → 切到模型目标
191188
fireEvent.click(screen.getByTestId("note-link-tab-model"));
192-
fireEvent.change(await screen.findByTestId("note-link-entity"), { target: { value: "31" } });
193-
// 已挂态确认按钮为「切换目标」(面板关闭前断言)
189+
fireEvent.click(await screen.findByTestId("note-link-row-31"));
194190
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).textContent).toContain("切换目标");
195191
fireEvent.click(screen.getByTestId("note-link-confirm"));
196192
await waitFor(() => {
@@ -218,3 +214,92 @@ describe("NoteLinkToSystem v0.14 C3 已挂态与切换", () => {
218214
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("delete_knowledge_link", { id: 50 }));
219215
});
220216
});
217+
218+
describe("NoteLinkToSystem REQ-286 行内轻建(即建即选)", () => {
219+
function withCreateMock() {
220+
invokeMock.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
221+
switch (cmd) {
222+
case "list_knowledge_systems": return systems;
223+
case "list_knowledge_nodes": {
224+
const created = args?.__created ? [{ id: 88, systemId: 2, parentId: null, type: "question", text: "为什么天空是蓝的?", orderIdx: 0, status: "active", createdAt: 0 }] : [];
225+
return args?.systemId === 2 ? [...nodes, ...created] : [];
226+
}
227+
case "list_knowledge_concepts": return args?.systemId === 2 ? concepts : [];
228+
case "list_knowledge_models": return args?.systemId === 2 ? models : [];
229+
case "list_links_by_target": return [];
230+
case "add_knowledge_node": {
231+
// 记录创建以便列表重载可见(简化:直接置标)
232+
return { id: 88, systemId: 2, parentId: (args?.parentId as number | null) ?? null, type: "question", text: args?.text, orderIdx: 0, status: "active", createdAt: 0 };
233+
}
234+
case "link_knowledge_target": return { id: 99 };
235+
case "delete_knowledge_link": return true;
236+
default: throw new Error(`unexpected: ${cmd}`);
237+
}
238+
});
239+
}
240+
241+
it("问题轻建挂体系根:输入名回车 → 新建即选 → 确认挂接 nodeId=新节点", async () => {
242+
withCreateMock();
243+
await openWithSystem();
244+
const search = await screen.findByTestId("note-link-search");
245+
fireEvent.change(search, { target: { value: "为什么天空是蓝的?" } });
246+
const createBtn = await screen.findByTestId("note-link-create");
247+
fireEvent.click(createBtn);
248+
await waitFor(() => {
249+
expect(invokeMock).toHaveBeenCalledWith("add_knowledge_node", {
250+
systemId: 2, parentId: null, nodeType: "question", text: "为什么天空是蓝的?",
251+
});
252+
});
253+
// 即建即选:确认按钮可用,link 携带新节点 id
254+
await waitFor(() => {
255+
expect((screen.getByTestId("note-link-confirm") as HTMLButtonElement).disabled).toBe(false);
256+
});
257+
fireEvent.click(screen.getByTestId("note-link-confirm"));
258+
await waitFor(() => {
259+
expect(invokeMock).toHaveBeenCalledWith("link_knowledge_target", {
260+
systemId: 2, nodeId: 88, targetType: "note", targetId: 7,
261+
});
262+
});
263+
});
264+
265+
it("问题轻建挂选中节点下(父锚点=行点击)", async () => {
266+
withCreateMock();
267+
await openWithSystem();
268+
fireEvent.click(await screen.findByTestId("note-link-row-11")); // 父锚点
269+
const search = await screen.findByTestId("note-link-search");
270+
fireEvent.change(search, { target: { value: "子问题:怎么测白平衡?" } });
271+
fireEvent.click(await screen.findByTestId("note-link-create"));
272+
await waitFor(() => {
273+
expect(invokeMock).toHaveBeenCalledWith("add_knowledge_node", expect.objectContaining({
274+
systemId: 2, parentId: 11, nodeType: "question",
275+
}));
276+
});
277+
});
278+
279+
it("概念轻建:切概念 tab 输入名回车 → add_knowledge_concept 名称级落库", async () => {
280+
invokeMock.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
281+
switch (cmd) {
282+
case "list_knowledge_systems": return systems;
283+
case "list_knowledge_nodes": return args?.systemId === 2 ? nodes : [];
284+
case "list_knowledge_concepts": return args?.systemId === 2 ? concepts : [];
285+
case "list_knowledge_models": return args?.systemId === 2 ? models : [];
286+
case "list_links_by_target": return [];
287+
case "add_knowledge_concept":
288+
return { id: 22, systemId: 2, name: args?.name, essence: null, boundary: null, relation: null, status: "core", lastAppliedAt: null, createdAt: 0, updatedAt: 0 };
289+
default: throw new Error(`unexpected: ${cmd}`);
290+
}
291+
});
292+
await openWithSystem();
293+
fireEvent.click(screen.getByTestId("note-link-tab-concept"));
294+
const search = await screen.findByTestId("note-link-search");
295+
fireEvent.keyDown(search, { key: "Enter" }); // 空输入不建
296+
expect(invokeMock).not.toHaveBeenCalledWith("add_knowledge_concept", expect.anything());
297+
fireEvent.change(search, { target: { value: "色温" } });
298+
fireEvent.keyDown(search, { key: "Enter" });
299+
await waitFor(() => {
300+
expect(invokeMock).toHaveBeenCalledWith("add_knowledge_concept", {
301+
systemId: 2, name: "色温", essence: null, boundary: null, relation: null,
302+
});
303+
});
304+
});
305+
});

0 commit comments

Comments
 (0)