Skip to content

Commit eaef8d8

Browse files
committed
feat(knowledge): 示例体系浏览与一键复制(具象化入口)
1 parent 7f58524 commit eaef8d8

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* KnowledgeSampleView.test.tsx — 示例体系浏览 + 复制(v0.13.7 具象化)。
4+
*
5+
* @ai-context: 复制流——无全局体系先开全局向导(onNeedGlobal 回调),
6+
* 有全局则直接逐条落库;命令顺序 golden:system→node×7→concept×3→model×1。
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 KnowledgeSampleView from "./KnowledgeSampleView";
15+
16+
beforeEach(() => {
17+
invokeMock.mockReset();
18+
invokeMock.mockImplementation(async (cmd: string) => {
19+
switch (cmd) {
20+
case "list_knowledge_systems": return [];
21+
case "create_knowledge_system": return { id: 100, parentSystemId: null, name: "摄影", kind: "domain", coreQuestion: "q", status: "active", createdAt: 0, updatedAt: 0 };
22+
case "add_knowledge_node": return { id: 1 };
23+
case "add_knowledge_concept": return { id: 1 };
24+
case "add_knowledge_model": return { id: 1 };
25+
default: throw new Error(`unexpected: ${cmd}`);
26+
}
27+
});
28+
});
29+
30+
afterEach(() => cleanup());
31+
32+
describe("KnowledgeSampleView", () => {
33+
it("浏览:渲染体系名/问题树节点/概念/模型", () => {
34+
render(<KnowledgeSampleView onCopied={vi.fn()} onNeedGlobal={vi.fn()} />);
35+
expect(screen.getByTestId("sample-view").textContent).toContain("摄影");
36+
expect(screen.getByTestId("sample-view").textContent).toContain("曝光三角");
37+
});
38+
39+
it("无全局体系:点复制触发 onNeedGlobal(不落库)", async () => {
40+
const onNeedGlobal = vi.fn();
41+
render(<KnowledgeSampleView onCopied={vi.fn()} onNeedGlobal={onNeedGlobal} />);
42+
fireEvent.click(await screen.findByTestId("sample-copy"));
43+
expect(onNeedGlobal).toHaveBeenCalledTimes(1);
44+
expect(invokeMock).not.toHaveBeenCalledWith("create_knowledge_system", expect.anything());
45+
});
46+
47+
it("有全局体系:复制按序落库并回调 onCopied", async () => {
48+
invokeMock.mockImplementation(async (cmd: string) => {
49+
if (cmd === "list_knowledge_systems") {
50+
return [{ id: 1, parentSystemId: null, name: "全局", kind: "global", coreQuestion: "q", status: "active", createdAt: 0, updatedAt: 0 }];
51+
}
52+
if (cmd === "create_knowledge_system") return { id: 100, parentSystemId: 1, name: "摄影", kind: "domain", coreQuestion: "q", status: "active", createdAt: 0, updatedAt: 0 };
53+
if (cmd === "add_knowledge_node") return { id: 1 };
54+
if (cmd === "add_knowledge_concept") return { id: 1 };
55+
if (cmd === "add_knowledge_model") return { id: 1 };
56+
throw new Error(`unexpected: ${cmd}`);
57+
});
58+
const onCopied = vi.fn();
59+
render(<KnowledgeSampleView onCopied={onCopied} onNeedGlobal={vi.fn()} />);
60+
fireEvent.click(await screen.findByTestId("sample-copy"));
61+
await waitFor(() => expect(onCopied).toHaveBeenCalledTimes(1));
62+
const calls = invokeMock.mock.calls.map((c) => c[0]);
63+
// calls[0] 是 mount 预检 list_knowledge_systems(另见 useEffect)——复制序列从 create 开始
64+
const createIdx = calls.findIndex((c) => c === "create_knowledge_system");
65+
const firstNodeIdx = calls.findIndex((c) => c === "add_knowledge_node");
66+
const firstConceptIdx = calls.findIndex((c) => c === "add_knowledge_concept");
67+
const firstModelIdx = calls.findIndex((c) => c === "add_knowledge_model");
68+
expect(createIdx).toBeGreaterThanOrEqual(0);
69+
expect(firstNodeIdx).toBeGreaterThan(createIdx);
70+
expect(firstConceptIdx).toBeGreaterThan(firstNodeIdx);
71+
expect(firstModelIdx).toBeGreaterThan(firstConceptIdx);
72+
expect(calls.filter((c) => c === "add_knowledge_node").length).toBe(7);
73+
expect(calls.filter((c) => c === "add_knowledge_concept").length).toBe(3);
74+
expect(calls.filter((c) => c === "add_knowledge_model").length).toBe(1);
75+
});
76+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* KnowledgeSampleView — 摄影示例体系浏览 + 一键复制(v0.13.7 具象化)。
3+
*
4+
* @ai-context: 示例≠预填(纪律裁决 2026-08-24)——浏览是被动参照;复制是
5+
* 主动获得骨架("待改造"非"已完成")。无全局体系时阻塞等待
6+
* 用户先建全局(onNeedGlobal 由父级开全局向导)。
7+
* @ai-context: 复制落库顺序 golden——create_knowledge_system → 逐条
8+
* add_knowledge_node(parentId 由"示例内数组下标"映射为真实 id,
9+
* 串行保证父先于子)→ 概念 ×3 → 模型 ×1。顺序在测试有断言,勿改。
10+
*/
11+
import { useEffect, useMemo, useState } from "react";
12+
import type { ReactNode } from "react";
13+
import { invoke } from "@tauri-apps/api/core";
14+
import { SAMPLE_SYSTEM } from "../utils/knowledgeSample";
15+
import type { KnowledgeSystem } from "../types/knowledge";
16+
17+
interface Props {
18+
/** 复制完成回调(父级刷新体系列表并选中新体系) */
19+
onCopied: (system: KnowledgeSystem) => void;
20+
/** 无全局体系时触发(父级打开全局创建向导;完成后父级调用 refreshGlobal 重试) */
21+
onNeedGlobal: () => void;
22+
/** 外部刷新信号(全局创建完成后父级触发) */
23+
refreshGlobal?: number;
24+
}
25+
26+
export default function KnowledgeSampleView({ onCopied, onNeedGlobal, refreshGlobal = 0 }: Props) {
27+
const [globalSystem, setGlobalSystem] = useState<KnowledgeSystem | null>(null);
28+
const [busy, setBusy] = useState(false);
29+
const [err, setErr] = useState("");
30+
31+
const checkGlobal = async () => {
32+
try {
33+
const list = await invoke<KnowledgeSystem[]>("list_knowledge_systems");
34+
setGlobalSystem(list.find((s) => s.kind === "global") ?? null);
35+
} catch (e) {
36+
setErr(`体系检查失败: ${e}`);
37+
}
38+
};
39+
useEffect(() => { void checkGlobal(); }, [refreshGlobal]);
40+
41+
const tree = useMemo(() => {
42+
const byParent = new Map<number | null, { type: string; text: string; index: number }[]>();
43+
SAMPLE_SYSTEM.nodes.forEach((n, i) => {
44+
const list = byParent.get(n.parentId) ?? [];
45+
list.push({ type: n.type, text: n.text, index: i });
46+
byParent.set(n.parentId, list);
47+
});
48+
return byParent;
49+
}, []);
50+
51+
const renderNodes = (parentId: number | null, depth: number): ReactNode =>
52+
(tree.get(parentId) ?? []).map((n) => (
53+
<div key={n.index} style={{ paddingLeft: depth * 14, fontSize: 12, marginTop: 2 }}>
54+
{n.type === "question" ? "❓" : n.type === "scenario" ? "🎯" : "📂"} {n.text}
55+
{renderNodes(n.index, depth + 1)}
56+
</div>
57+
));
58+
59+
const runCopy = async () => {
60+
if (busy) return;
61+
if (!globalSystem) { onNeedGlobal(); return; }
62+
setBusy(true); setErr("");
63+
try {
64+
const sys = await invoke<KnowledgeSystem>("create_knowledge_system", {
65+
name: SAMPLE_SYSTEM.name,
66+
kind: SAMPLE_SYSTEM.kind,
67+
parentSystemId: globalSystem.id,
68+
coreQuestion: SAMPLE_SYSTEM.coreQuestion,
69+
});
70+
// 示例内 parentId 是 nodes 数组下标索引 → 落库时维护 index → 真实 node id 映射。
71+
// 子节点 parentId 恒指向更小的下标(父先于子),串行创建即可满足映射。
72+
const idMap = new Map<number, number>();
73+
for (let index = 0; index < SAMPLE_SYSTEM.nodes.length; index++) {
74+
const n = SAMPLE_SYSTEM.nodes[index];
75+
const realParentId = n.parentId == null ? undefined : idMap.get(n.parentId);
76+
const created = await invoke<{ id: number }>("add_knowledge_node", {
77+
systemId: sys.id,
78+
parentId: realParentId,
79+
nodeType: n.type,
80+
text: n.text,
81+
});
82+
idMap.set(index, created.id);
83+
}
84+
for (const c of SAMPLE_SYSTEM.concepts) {
85+
await invoke("add_knowledge_concept", {
86+
systemId: sys.id, name: c.name, essence: c.essence, boundary: c.boundary, relation: c.relation,
87+
});
88+
}
89+
for (const m of SAMPLE_SYSTEM.models) {
90+
await invoke("add_knowledge_model", {
91+
systemId: sys.id, name: m.name, disciplines: m.disciplines, claim: m.claim,
92+
validWhen: m.validWhen, invalidWhen: m.invalidWhen,
93+
});
94+
}
95+
onCopied(sys);
96+
} catch (e) {
97+
setErr(`复制失败: ${e}`);
98+
} finally {
99+
setBusy(false);
100+
}
101+
};
102+
103+
return (
104+
<div data-testid="sample-view" style={{ width: "100%", maxWidth: 560, textAlign: "left", background: "#fff", border: "1px solid #e5e7eb", borderRadius: 10, padding: 16 }}>
105+
<div style={{ fontWeight: 700, fontSize: 14, color: "#0f766e", marginBottom: 4 }}>📷 示例:领域体系「{SAMPLE_SYSTEM.name}」</div>
106+
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 10 }}>核心问题:{SAMPLE_SYSTEM.coreQuestion}</div>
107+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>🌳 问题树</div>
108+
{renderNodes(null, 0)}
109+
<div style={{ fontSize: 12, fontWeight: 600, marginTop: 8, marginBottom: 4 }}>🧬 概念(三问)</div>
110+
{SAMPLE_SYSTEM.concepts.map((c) => (
111+
<div key={c.name} style={{ fontSize: 12, marginBottom: 6, padding: 6, background: "#f9fafb", borderRadius: 6 }}>
112+
<b>{c.name}</b>
113+
<div style={{ color: "#6b7280" }}>本质:{c.essence}</div>
114+
<div style={{ color: "#6b7280" }}>边界:{c.boundary}</div>
115+
<div style={{ color: "#6b7280" }}>联系:{c.relation}</div>
116+
</div>
117+
))}
118+
<div style={{ fontSize: 12, fontWeight: 600, marginTop: 8, marginBottom: 4 }}>⚙ 模型</div>
119+
{SAMPLE_SYSTEM.models.map((m) => (
120+
<div key={m.name} style={{ fontSize: 12, padding: 6, background: "#f9fafb", borderRadius: 6 }}>
121+
<b>{m.name}</b>({m.disciplines.join("/")})
122+
<div style={{ color: "#6b7280" }}>主张:{m.claim}</div>
123+
<div style={{ color: "#6b7280" }}>何时成立:{m.validWhen} · 何时失效:{m.invalidWhen}</div>
124+
</div>
125+
))}
126+
<button
127+
data-testid="sample-copy"
128+
onClick={() => void runCopy()}
129+
disabled={busy}
130+
style={{ marginTop: 12, fontSize: 13, cursor: "pointer", padding: "8px 18px", borderRadius: 6, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e", fontWeight: 600 }}
131+
>
132+
{busy ? "复制中…" : "📋 复制为我的体系"}
133+
</button>
134+
{err && <div style={{ fontSize: 11, color: "#dc2626", marginTop: 6 }}>{err}</div>}
135+
</div>
136+
);
137+
}

0 commit comments

Comments
 (0)