|
| 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