Skip to content

Commit e459cf1

Browse files
committed
feat(knowledge): 笔记挂到体系选择器(触点②,零 id 断点修复)
1 parent b824c1e commit e459cf1

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* NoteLinkToSystem.test.tsx — 笔记「挂到体系」选择器(v0.13.7 触点②)。
4+
*
5+
* @ai-context: 修复手工输 id 断点——用户零 id 知识:选体系→选节点→
6+
* 确认,targetType=note 与 targetId 由组件自动携带。
7+
*/
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
10+
11+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
12+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
13+
14+
import NoteLinkToSystem from "./NoteLinkToSystem";
15+
16+
const systems = [
17+
{ id: 1, parentSystemId: null, name: "全局", kind: "global", coreQuestion: "q", status: "active", createdAt: 0, updatedAt: 0 },
18+
{ id: 2, parentSystemId: 1, name: "摄影", kind: "domain", coreQuestion: null, status: "active", createdAt: 0, updatedAt: 0 },
19+
];
20+
const nodes = [
21+
{ id: 11, systemId: 2, parentId: null, type: "question", text: "照片为什么发灰?", orderIdx: 0, status: "active", createdAt: 0 },
22+
];
23+
24+
beforeEach(() => {
25+
invokeMock.mockReset();
26+
invokeMock.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
27+
switch (cmd) {
28+
case "list_knowledge_systems": return systems;
29+
case "list_knowledge_nodes": return args?.systemId === 2 ? nodes : [];
30+
case "list_knowledge_links": return [];
31+
case "link_knowledge_target": return { id: 99 };
32+
case "delete_knowledge_link": return true;
33+
default: throw new Error(`unexpected: ${cmd}`);
34+
}
35+
});
36+
});
37+
38+
afterEach(() => cleanup());
39+
40+
describe("NoteLinkToSystem", () => {
41+
it("未挂接时显示「挂到体系」按钮;点开选体系选节点确认后调用 link(零 id)", async () => {
42+
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
43+
fireEvent.click(await screen.findByTestId("note-link-open"));
44+
fireEvent.change(await screen.findByTestId("note-link-system"), { target: { value: "2" } });
45+
fireEvent.change(await screen.findByTestId("note-link-node"), { target: { value: "11" } });
46+
fireEvent.click(screen.getByTestId("note-link-confirm"));
47+
await waitFor(() => {
48+
expect(invokeMock).toHaveBeenCalledWith("link_knowledge_target", {
49+
systemId: 2, nodeId: 11, targetType: "note", targetId: 7,
50+
});
51+
});
52+
});
53+
54+
it("已挂接时显示「已挂 · 体系名」并可取消", async () => {
55+
invokeMock.mockImplementation(async (cmd: string) => {
56+
switch (cmd) {
57+
case "list_knowledge_systems": return systems;
58+
case "list_knowledge_nodes": return nodes;
59+
case "list_knowledge_links":
60+
return [{ id: 50, systemId: 2, nodeId: 11, conceptId: null, modelId: null, targetType: "note", targetId: 7, createdAt: 0 }];
61+
case "delete_knowledge_link": return true;
62+
default: throw new Error(`unexpected: ${cmd}`);
63+
}
64+
});
65+
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
66+
const btn = await screen.findByTestId("note-link-open");
67+
expect(btn.textContent).toContain("已挂 · 摄影");
68+
fireEvent.click(btn);
69+
fireEvent.click(await screen.findByTestId("note-link-unlink"));
70+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("delete_knowledge_link", { id: 50 }));
71+
});
72+
73+
it("无体系时提示先创建体系(不显示确认按钮)", async () => {
74+
invokeMock.mockImplementation(async (cmd: string) => {
75+
if (cmd === "list_knowledge_systems") return [];
76+
if (cmd === "list_knowledge_links") return [];
77+
throw new Error(`unexpected: ${cmd}`);
78+
});
79+
render(<NoteLinkToSystem noteId={7} onChanged={vi.fn()} />);
80+
fireEvent.click(await screen.findByTestId("note-link-open"));
81+
expect(await screen.findByTestId("note-link-empty")).toBeTruthy();
82+
expect(screen.queryByTestId("note-link-confirm")).toBeNull();
83+
});
84+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* NoteLinkToSystem — 笔记「挂到体系」选择器(v0.13.7 触点②)。
3+
*
4+
* @ai-context: 修复知识链接手工输 id 断点(v0.13.1 遗留)——用户在笔记
5+
* 阅读视图把当前笔记挂到体系节点:选体系→选节点→确认,
6+
* targetType=note/targetId 自动携带,用户零 id 知识。
7+
* @ai-context: 已挂状态反查 list_knowledge_links(后端强制 system_id——
8+
* 按体系逐次查询聚合,再按 targetType=note 过滤;与触点①
9+
* GroupSidebar 全量直取不同——本组件查询时体系列表已就绪);
10+
* 取消走 delete_knowledge_link;变更经 onChanged 通知刷新。
11+
*/
12+
import { useCallback, useEffect, useMemo, useState } from "react";
13+
import { invoke } from "@tauri-apps/api/core";
14+
import type { KnowledgeLink, KnowledgeNode, KnowledgeSystem } from "../types/knowledge";
15+
16+
interface Props {
17+
noteId: number;
18+
/** 挂接/取消后刷新回调(NotesPage 重载引用列表) */
19+
onChanged: () => void;
20+
}
21+
22+
export default function NoteLinkToSystem({ noteId, onChanged }: Props) {
23+
const [open, setOpen] = useState(false);
24+
const [systems, setSystems] = useState<KnowledgeSystem[]>([]);
25+
const [nodes, setNodes] = useState<KnowledgeNode[]>([]);
26+
const [systemId, setSystemId] = useState<number | null>(null);
27+
const [nodeId, setNodeId] = useState<number | null>(null);
28+
const [linked, setLinked] = useState<KnowledgeLink | null>(null);
29+
const [busy, setBusy] = useState(false);
30+
const [err, setErr] = useState("");
31+
32+
const load = useCallback(async () => {
33+
try {
34+
const sysList = await invoke<KnowledgeSystem[]>("list_knowledge_systems");
35+
setSystems(sysList);
36+
// list_knowledge_links 后端强制 system_id(None 报"必须指定体系")——
37+
// 按非归档体系逐次查询后聚合,再反查当前笔记的挂接
38+
const active = sysList.filter((s) => s.status !== "archived");
39+
const linkGroups = await Promise.all(
40+
active.map((s) => invoke<KnowledgeLink[]>("list_knowledge_links", { systemId: s.id })),
41+
);
42+
const links = linkGroups.flat();
43+
setLinked(links.find((l) => l.targetType === "note" && l.targetId === noteId) ?? null);
44+
} catch (e) { setErr(`体系加载失败: ${e}`); }
45+
}, [noteId]);
46+
47+
useEffect(() => { void load(); }, [load]);
48+
49+
// 选体系后拉取该体系节点树
50+
useEffect(() => {
51+
if (systemId == null) { setNodes([]); setNodeId(null); return; }
52+
invoke<KnowledgeNode[]>("list_knowledge_nodes", { systemId })
53+
.then(setNodes)
54+
.catch((e) => setErr(`节点加载失败: ${e}`));
55+
}, [systemId]);
56+
57+
const domainSystems = useMemo(() => systems.filter((s) => s.status !== "archived"), [systems]);
58+
const effectiveSystemId = systemId ?? linked?.systemId ?? null;
59+
60+
const confirmLink = async () => {
61+
if (effectiveSystemId == null) return;
62+
setBusy(true); setErr("");
63+
try {
64+
await invoke("link_knowledge_target", {
65+
systemId: effectiveSystemId,
66+
nodeId: nodeId ?? undefined,
67+
targetType: "note",
68+
targetId: noteId,
69+
});
70+
setOpen(false);
71+
await load();
72+
onChanged();
73+
} catch (e) { setErr(`挂接失败: ${e}`); } finally { setBusy(false); }
74+
};
75+
76+
const unlink = async () => {
77+
if (!linked) return;
78+
setBusy(true); setErr("");
79+
try {
80+
await invoke("delete_knowledge_link", { id: linked.id });
81+
setLinked(null);
82+
setOpen(false);
83+
onChanged();
84+
} catch (e) { setErr(`取消挂接失败: ${e}`); } finally { setBusy(false); }
85+
};
86+
87+
const linkedSystem = systems.find((s) => s.id === linked?.systemId) ?? null;
88+
89+
return (
90+
<span style={{ display: "inline-flex", flexDirection: "column", alignItems: "flex-start", gap: 4 }}>
91+
<button
92+
data-testid="note-link-open"
93+
onClick={() => setOpen((v) => !v)}
94+
style={{
95+
fontSize: 12, cursor: "pointer", padding: "4px 10px", borderRadius: 4,
96+
border: linked ? "1px solid #99f6e4" : "1px solid #d1d5db",
97+
background: linked ? "#f0fdfa" : "#fff",
98+
color: linked ? "#0f766e" : "#374151",
99+
}}
100+
title={linked ? "点击管理体系挂接" : "把这条笔记挂到体系的问题/概念上"}
101+
>
102+
{linked ? `🧭 已挂 · ${linkedSystem?.name ?? "体系"}` : "🧭 挂到体系"}
103+
</button>
104+
105+
{open && (
106+
<div
107+
data-testid="note-link-panel"
108+
style={{ position: "absolute", zIndex: 30, marginTop: 30, padding: 10, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, boxShadow: "0 4px 16px rgba(0,0,0,0.12)", display: "flex", flexDirection: "column", gap: 6, width: 260 }}
109+
>
110+
{domainSystems.length === 0 ? (
111+
<div data-testid="note-link-empty" style={{ fontSize: 12, color: "#9ca3af" }}>暂无体系——请先到「🧠 体系」页创建。</div>
112+
) : (
113+
<>
114+
<select
115+
data-testid="note-link-system"
116+
value={effectiveSystemId ?? ""}
117+
onChange={(e) => setSystemId(Number(e.target.value) || null)}
118+
style={{ fontSize: 12, padding: "4px 6px", border: "1px solid #e5e7eb", borderRadius: 4 }}
119+
>
120+
<option value="">选择体系…</option>
121+
{domainSystems.map((s) => (
122+
<option key={s.id} value={s.id}>{s.kind === "global" ? "🌐 " : "📂 "}{s.name}</option>
123+
))}
124+
</select>
125+
<select
126+
data-testid="note-link-node"
127+
value={nodeId ?? ""}
128+
onChange={(e) => setNodeId(Number(e.target.value) || null)}
129+
style={{ fontSize: 12, padding: "4px 6px", border: "1px solid #e5e7eb", borderRadius: 4 }}
130+
disabled={effectiveSystemId == null}
131+
>
132+
<option value="">挂到体系根(不选节点)</option>
133+
{nodes.map((n) => (
134+
<option key={n.id} value={n.id}>{n.text.slice(0, 24)}</option>
135+
))}
136+
</select>
137+
{linked ? (
138+
<button
139+
data-testid="note-link-unlink"
140+
onClick={() => void unlink()}
141+
disabled={busy}
142+
style={{ fontSize: 12, cursor: "pointer", padding: "4px 0", borderRadius: 4, border: "1px solid #fecaca", background: "#fff", color: "#dc2626" }}
143+
>
144+
{busy ? "处理中…" : "取消挂接"}
145+
</button>
146+
) : (
147+
<button
148+
data-testid="note-link-confirm"
149+
onClick={() => void confirmLink()}
150+
disabled={busy || effectiveSystemId == null}
151+
style={{ fontSize: 12, cursor: "pointer", padding: "4px 0", borderRadius: 4, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e" }}
152+
>
153+
{busy ? "挂接中…" : "确认挂接"}
154+
</button>
155+
)}
156+
</>
157+
)}
158+
{err && <div style={{ fontSize: 11, color: "#dc2626" }}>{err}</div>}
159+
</div>
160+
)}
161+
</span>
162+
);
163+
}

0 commit comments

Comments
 (0)