Skip to content

Commit 1269d7a

Browse files
committed
feat(knowledge): 体系页 UI 与创建向导
1 parent f91d802 commit 1269d7a

9 files changed

Lines changed: 1641 additions & 1 deletion

‎app/src/App.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,20 @@ import NotesPage from "./pages/NotesPage";
1818
import SessionsPage from "./pages/SessionsPage";
1919
// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
2020
import SettingsPage from "./pages/SettingsPage";
21+
// v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不入每日复习面)
22+
import KnowledgePage from "./pages/KnowledgePage";
2123
import AppErrorBoundary from "./components/AppErrorBoundary";
2224
import CaptureFloatPanel from "./components/CaptureFloatPanel";
2325
import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
2426
import type { AiTaskState } from "./types";
2527

26-
type Page = "classroom" | "sessions" | "notes" | "settings";
28+
type Page = "classroom" | "sessions" | "notes" | "knowledge" | "settings";
2729

2830
const NAV_ITEMS: { key: Page; label: string }[] = [
2931
{ key: "classroom", label: "📡 课堂助手" },
3032
{ key: "sessions", label: "🗂 会话" },
3133
{ key: "notes", label: "📝 笔记" },
34+
{ key: "knowledge", label: "🧠 体系" },
3235
{ key: "settings", label: "⚙ 设置" },
3336
];
3437

@@ -270,6 +273,10 @@ function App() {
270273
}}
271274
/>
272275
</div>
276+
<div style={{ flex: 1, display: page === "knowledge" ? "block" : "none", overflow: "hidden" }}>
277+
{/* v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不与每日复习面混排) */}
278+
<KnowledgePage />
279+
</div>
273280
<div style={{ flex: 1, display: page === "settings" ? "block" : "none", overflow: "hidden" }}>
274281
{/* 2026-08-21:设置页(保留挂载——面板状态不因切页重置;TD-004 同模式) */}
275282
<SettingsPage />
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* KnowledgeDetailPanel — 知识体系右栏详情面板(v0.13.1 §五)。
3+
*
4+
* @ai-context: 按选中实体三态分派——节点(只读信息 + 引用)/概念(三问编辑)/
5+
* 模型(跨国学科断言编辑)。新建态(selection.id===null)复用编辑器
6+
* 走 add_* command(编辑器与列表共用同一 UI,免重复表单)。
7+
* @ai-context: 概念名全局唯一(§二)——保存时依赖 command 层唯一校验报错,
8+
* 前端仅做非空拦截;三问(本质/边界/联系)为概念记忆面的提问骨架。
9+
* @ai-context: 面板整体可折叠(§五 折叠按钮)——折叠成窄栏保留可读性,
10+
* 不占主树空间。
11+
*/
12+
import { useEffect, useState } from "react";
13+
import { invoke } from "@tauri-apps/api/core";
14+
import type {
15+
KnowledgeSystem, KnowledgeNode, KnowledgeConcept, KnowledgeModel,
16+
KnowledgeLink, KnowledgeSelection, KnowledgeConceptStatus, KnowledgeModelStatus,
17+
} from "../types/knowledge";
18+
import { nodeTypeLabel, conceptStatusLabel } from "../types/knowledge";
19+
import KnowledgeLinkSection from "./KnowledgeLinkSection";
20+
21+
interface Props {
22+
system: KnowledgeSystem;
23+
nodes: KnowledgeNode[];
24+
concepts: KnowledgeConcept[];
25+
models: KnowledgeModel[];
26+
links: KnowledgeLink[];
27+
selection: KnowledgeSelection | null;
28+
onChanged: () => void;
29+
}
30+
31+
/** 概念状态选项(core/watching/archived) */
32+
const CONCEPT_STATUSES: KnowledgeConceptStatus[] = ["core", "watching", "archived"];
33+
/** 模型状态选项(active/watching/archived) */
34+
const MODEL_STATUSES: KnowledgeModelStatus[] = ["active", "watching", "archived"];
35+
36+
export default function KnowledgeDetailPanel({ system, nodes, concepts, models, links, selection, onChanged }: Props) {
37+
const [collapsed, setCollapsed] = useState(false);
38+
const [saving, setSaving] = useState(false);
39+
const [err, setErr] = useState("");
40+
// 概念/模型编辑草稿(与选中实体同步)
41+
const [cName, setCName] = useState("");
42+
const [cEssence, setCEssence] = useState("");
43+
const [cBoundary, setCBoundary] = useState("");
44+
const [cRelation, setCRelation] = useState("");
45+
const [cStatus, setCStatus] = useState<KnowledgeConceptStatus>("core");
46+
const [mName, setMName] = useState("");
47+
const [mDisciplines, setMDisciplines] = useState("");
48+
const [mClaim, setMClaim] = useState("");
49+
const [mValidWhen, setMValidWhen] = useState("");
50+
const [mInvalidWhen, setMInvalidWhen] = useState("");
51+
const [mStatus, setMStatus] = useState<KnowledgeModelStatus>("active");
52+
53+
// 选中实体变化 → 同步编辑器草稿(新建态清空)
54+
useEffect(() => {
55+
if (selection?.type === "concept") {
56+
const c = concepts.find((x) => x.id === selection.id) ?? null;
57+
setCName(c?.name ?? "");
58+
setCEssence(c?.essence ?? "");
59+
setCBoundary(c?.boundary ?? "");
60+
setCRelation(c?.relation ?? "");
61+
setCStatus(c?.status ?? "core");
62+
setErr("");
63+
}
64+
if (selection?.type === "model") {
65+
const m = models.find((x) => x.id === selection.id) ?? null;
66+
setMName(m?.name ?? "");
67+
setMDisciplines(m ? m.disciplines.join(", ") : "");
68+
setMClaim(m?.claim ?? "");
69+
setMValidWhen(m?.validWhen ?? "");
70+
setMInvalidWhen(m?.invalidWhen ?? "");
71+
setMStatus(m?.status ?? "active");
72+
setErr("");
73+
}
74+
}, [selection, concepts, models]);
75+
76+
const saveConcept = async () => {
77+
if (!cName.trim()) { setErr("概念名不能为空"); return; }
78+
setSaving(true); setErr("");
79+
try {
80+
const common = { name: cName.trim(), essence: cEssence.trim() || null, boundary: cBoundary.trim() || null, relation: cRelation.trim() || null };
81+
if (selection?.id == null) await invoke("add_knowledge_concept", { systemId: system.id, ...common });
82+
else await invoke("update_knowledge_concept", { id: selection.id, ...common, status: cStatus });
83+
onChanged();
84+
} catch (e) { setErr(`概念保存失败: ${e}`); } finally { setSaving(false); }
85+
};
86+
87+
const saveModel = async () => {
88+
if (!mName.trim()) { setErr("模型名不能为空"); return; }
89+
setSaving(true); setErr("");
90+
try {
91+
const disciplines = mDisciplines.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
92+
if (disciplines.length === 0) { setErr("至少填写一个学科"); setSaving(false); return; }
93+
const common = {
94+
name: mName.trim(), disciplines,
95+
claim: mClaim.trim() || null, validWhen: mValidWhen.trim() || null, invalidWhen: mInvalidWhen.trim() || null,
96+
};
97+
if (selection?.id == null) await invoke("add_knowledge_model", { systemId: system.id, ...common });
98+
else await invoke("update_knowledge_model", { id: selection.id, ...common, status: mStatus });
99+
onChanged();
100+
} catch (e) { setErr(`模型保存失败: ${e}`); } finally { setSaving(false); }
101+
};
102+
103+
// 折叠态:窄栏
104+
if (collapsed) {
105+
return (
106+
<div data-testid="detail-panel" style={{ width: 34, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", alignItems: "center", justifyContent: "center", background: "#fafafa" }}>
107+
<button data-testid="detail-expand" onClick={() => setCollapsed(false)} title="展开详情面板" style={{ border: "none", background: "none", cursor: "pointer", fontSize: 14, color: "#9ca3af" }}>
108+
◂
109+
</button>
110+
</div>
111+
);
112+
}
113+
114+
const selectedNode = selection?.type === "node" ? nodes.find((n) => n.id === selection.id) ?? null : null;
115+
116+
return (
117+
<div data-testid="detail-panel" style={{ width: 320, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0, background: "#fff" }}>
118+
<div style={{ display: "flex", alignItems: "center", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>
119+
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>详情</span>
120+
<button data-testid="detail-collapse" onClick={() => setCollapsed(true)} title="折叠详情面板" style={{ marginLeft: "auto", border: "none", background: "none", cursor: "pointer", fontSize: 14, color: "#9ca3af" }}>
121+
▸
122+
</button>
123+
</div>
124+
125+
<div style={{ flex: 1, overflowY: "auto", padding: 12 }}>
126+
{!selection && (
127+
<div style={{ fontSize: 12, color: "#9ca3af", textAlign: "center", paddingTop: 40 }}>
128+
从中间选择一个实体查看详情
129+
</div>
130+
)}
131+
132+
{/* ── 节点:只读信息 + 引用 ── */}
133+
{selection?.type === "node" && selectedNode && (
134+
<div>
135+
<div style={sectionTitle}>节点</div>
136+
<div style={{ fontSize: 14, fontWeight: 600, color: "#111827", marginBottom: 4 }}>{selectedNode.text}</div>
137+
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
138+
<span style={{ fontSize: 11, padding: "1px 8px", borderRadius: 8, background: "#f9fafb", color: "#6b7280" }}>{nodeTypeLabel[selectedNode.type]}</span>
139+
<span style={{ fontSize: 11, color: "#9ca3af", alignSelf: "center" }}>
140+
父节点:{selectedNode.parentId != null ? (nodes.find((n) => n.id === selectedNode.parentId)?.text ?? "—") : "(根)"}
141+
</span>
142+
</div>
143+
<KnowledgeLinkSection systemId={system.id} entityType="node" entityId={selectedNode.id} links={links} onChanged={onChanged} />
144+
</div>
145+
)}
146+
147+
{/* ── 概念:三问编辑 + 状态 + 引用(新建态无引用段) ── */}
148+
{selection?.type === "concept" && (
149+
<div>
150+
<div style={sectionTitle}>{selection.id == null ? "新建概念" : "概念"}</div>
151+
<label style={label}>名称 *</label>
152+
<input data-testid="concept-name" value={cName} onChange={(e) => setCName(e.target.value)} style={input} />
153+
<label style={label}>本质(它"是"什么)</label>
154+
<textarea data-testid="concept-essence" value={cEssence} onChange={(e) => setCEssence(e.target.value)} rows={2} style={textarea} />
155+
<label style={label}>边界(它"不是"什么)</label>
156+
<textarea data-testid="concept-boundary" value={cBoundary} onChange={(e) => setCBoundary(e.target.value)} rows={2} style={textarea} />
157+
<label style={label}>联系(它和什么相关)</label>
158+
<textarea data-testid="concept-relation" value={cRelation} onChange={(e) => setCRelation(e.target.value)} rows={2} style={textarea} />
159+
<label style={label}>状态</label>
160+
<select data-testid="concept-status" value={cStatus} onChange={(e) => setCStatus(e.target.value as KnowledgeConceptStatus)} style={select}>
161+
{CONCEPT_STATUSES.map((s) => <option key={s} value={s}>{conceptStatusLabel[s]}</option>)}
162+
</select>
163+
<SaveButton label={selection.id == null ? "创建概念" : "保存更改"} saving={saving} onClick={() => void saveConcept()} dataTestid="concept-save" />
164+
{selection.id != null && <KnowledgeLinkSection systemId={system.id} entityType="concept" entityId={selection.id} links={links} onChanged={onChanged} />}
165+
</div>
166+
)}
167+
168+
{/* ── 模型:多学科断言编辑 + 引用(新建态无引用段) ── */}
169+
{selection?.type === "model" && (
170+
<div>
171+
<div style={sectionTitle}>{selection.id == null ? "新建模型" : "模型"}</div>
172+
<label style={label}>名称 *</label>
173+
<input data-testid="model-name" value={mName} onChange={(e) => setMName(e.target.value)} style={input} />
174+
<label style={label}>学科(逗号分隔)</label>
175+
<input data-testid="model-disciplines" value={mDisciplines} onChange={(e) => setMDisciplines(e.target.value)} placeholder="编程, 学习方法" style={input} />
176+
<label style={label}>主张(claim)</label>
177+
<textarea data-testid="model-claim" value={mClaim} onChange={(e) => setMClaim(e.target.value)} rows={2} style={textarea} />
178+
<label style={label}>成立条件</label>
179+
<textarea data-testid="model-valid" value={mValidWhen} onChange={(e) => setMValidWhen(e.target.value)} rows={2} style={textarea} />
180+
<label style={label}>失效条件</label>
181+
<textarea data-testid="model-invalid" value={mInvalidWhen} onChange={(e) => setMInvalidWhen(e.target.value)} rows={2} style={textarea} />
182+
<label style={label}>状态</label>
183+
<select data-testid="model-status" value={mStatus} onChange={(e) => setMStatus(e.target.value as KnowledgeModelStatus)} style={select}>
184+
{MODEL_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
185+
</select>
186+
<SaveButton label={selection.id == null ? "创建模型" : "保存更改"} saving={saving} onClick={() => void saveModel()} dataTestid="model-save" />
187+
{selection.id != null && <KnowledgeLinkSection systemId={system.id} entityType="model" entityId={selection.id} links={links} onChanged={onChanged} />}
188+
</div>
189+
)}
190+
</div>
191+
192+
{err && <p data-testid="detail-error" style={{ padding: "6px 12px", fontSize: 12, color: "#dc2626", borderTop: "1px solid #f3f4f6" }}>{err}</p>}
193+
</div>
194+
);
195+
}
196+
197+
function SaveButton({ label, saving, onClick, dataTestid }: { label: string; saving: boolean; onClick: () => void; dataTestid: string }) {
198+
return (
199+
<button data-testid={dataTestid} onClick={onClick} disabled={saving} style={{ marginTop: 10, fontSize: 13, cursor: "pointer", padding: "6px 16px", borderRadius: 6, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e" }}>
200+
{saving ? "保存中…" : label}
201+
</button>
202+
);
203+
}
204+
205+
/** 段标题样式 */
206+
const sectionTitle: React.CSSProperties = { fontSize: 12, fontWeight: 700, color: "#374151", marginBottom: 8 };
207+
/** 标签样式 */
208+
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 600, color: "#6b7280", margin: "8px 0 3px" };
209+
/** 输入样式 */
210+
const input: React.CSSProperties = { width: "100%", fontSize: 13, padding: "6px 8px", border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box" };
211+
/** 文本域样式 */
212+
const textarea: React.CSSProperties = { ...input, resize: "vertical", fontFamily: "inherit" };
213+
/** 下拉样式 */
214+
const select: React.CSSProperties = { width: "100%", fontSize: 12, padding: "5px 6px", border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box" };

0 commit comments

Comments
 (0)