Skip to content

Commit 551d9a6

Browse files
committed
feat(knowledge): 概念双面体 UI
1 parent 0390d3c commit 551d9a6

11 files changed

Lines changed: 590 additions & 8 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* ConceptCardRow.test.tsx — 概念库三问一用卡片渲染测试(v0.13.2 §五)。
4+
*
5+
* @ai-context: 覆盖知识库概念行升级——三问摘要(essence 单行省略,缺则退边界/联系)、
6+
* 最近应用标签(lastAppliedAt null→"从未应用";v0.13.3 前恒为未应用)、
7+
* 状态徽标(conceptStatusLabel)。纯展示组件,不触 invoke。
8+
*/
9+
import { afterEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, render, screen } from "@testing-library/react";
11+
import type { KnowledgeConcept } from "../types/knowledge";
12+
13+
import ConceptCardRow from "./ConceptCardRow";
14+
15+
const concept: KnowledgeConcept = {
16+
id: 7, systemId: 1, name: "安全边际",
17+
essence: "赔率领先", boundary: "不是分散仓位", relation: "与仓位相关",
18+
status: "core", lastAppliedAt: null, createdAt: 0, updatedAt: 0,
19+
};
20+
21+
afterEach(() => cleanup());
22+
23+
function renderRow(c: KnowledgeConcept) {
24+
return render(<ConceptCardRow concept={c} selected={false} onSelect={vi.fn()} />);
25+
}
26+
27+
describe("ConceptCardRow 三问一用卡片", () => {
28+
it("渲染:名称 + 状态徽标 + 三问摘要(essence 单行)", () => {
29+
renderRow(concept);
30+
expect(screen.getByTestId("concept-row-7").textContent).toContain("安全边际");
31+
// 状态徽标(conceptStatusLabel:core → 核心)
32+
expect(screen.getByTestId("concept-row-7").textContent).toContain("核心");
33+
// 三问摘要取 essence
34+
expect(screen.getByTestId("concept-summary-7").textContent).toBe("赔率领先");
35+
});
36+
37+
it("从未应用标签:lastAppliedAt 为 null → 显示「从未应用」", () => {
38+
renderRow(concept);
39+
expect(screen.getByTestId("concept-applied-7").textContent).toBe("从未应用");
40+
});
41+
42+
it("最近应用标签:lastAppliedAt 有值 → 显示「最近应用」", () => {
43+
renderRow({ ...concept, lastAppliedAt: 1_700_000_000_000 });
44+
expect(screen.getByTestId("concept-applied-7").textContent).toBe("最近应用");
45+
});
46+
47+
it("三问摘要回退:essence 为 null → 取 boundary 首个非空", () => {
48+
renderRow({ ...concept, essence: null });
49+
expect(screen.getByTestId("concept-summary-7").textContent).toBe("不是分散仓位");
50+
});
51+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* ConceptCardRow — 概念库三问一用卡片(v0.13.2 §五 中栏概念列表升级)。
3+
*
4+
* @ai-context: 概念卡只展示记忆面"随手能读"的一层——name + 三问摘要(essence
5+
* 单行省略,缺则退边界/联系)+ 最近应用标签 + 状态徽标。不预填内容
6+
* (预填=假燃料);点击进详情面板做完整三问编辑。
7+
* @ai-context: 最近应用标签(§五)——lastAppliedAt 有值→"最近应用",null→
8+
* "从未应用";v0.13.3 接线"记一次使用"前恒为未应用,本版只展示占位。
9+
* @ai-context: 概念名全局唯一(§二 UNIQUE)——行点击只选中,不做改名入口。
10+
*/
11+
import type { KnowledgeConcept } from "../types/knowledge";
12+
import { conceptStatusLabel } from "../types/knowledge";
13+
14+
interface Props {
15+
concept: KnowledgeConcept;
16+
/** 是否选中(行高亮 + 边框) */
17+
selected: boolean;
18+
/** 单击选中回调节奏(父页 setSelection) */
19+
onSelect: () => void;
20+
}
21+
22+
export default function ConceptCardRow({ concept, selected, onSelect }: Props) {
23+
// 三问摘要:essence 优先;缺(null/空)则取 boundary/relation 首个非空(§五)
24+
const summary = concept.essence || concept.boundary || concept.relation || "";
25+
const appliedLabel = concept.lastAppliedAt != null ? "最近应用" : "从未应用";
26+
27+
return (
28+
<div
29+
data-testid={`concept-row-${concept.id}`}
30+
onClick={onSelect}
31+
style={{
32+
padding: "6px 8px", borderRadius: 6, cursor: "pointer",
33+
background: selected ? "#f0fdfa" : "transparent",
34+
border: selected ? "1px solid #99f6e4" : "1px solid transparent",
35+
}}
36+
>
37+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
38+
<span style={{ fontSize: 13, fontWeight: 500, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{concept.name}</span>
39+
<span style={{ fontSize: 10, color: "#7c3aed", background: "#faf5ff", borderRadius: 8, padding: "0 5px" }}>{conceptStatusLabel[concept.status]}</span>
40+
</div>
41+
{summary && (
42+
<div data-testid={`concept-summary-${concept.id}`} style={{ fontSize: 11, color: "#9ca3af", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
43+
{summary}
44+
</div>
45+
)}
46+
<div data-testid={`concept-applied-${concept.id}`} style={{ fontSize: 10, color: concept.lastAppliedAt != null ? "#0f766e" : "#9ca3af", marginTop: 2 }}>
47+
{appliedLabel}
48+
</div>
49+
</div>
50+
);
51+
}

‎app/src/components/KnowledgeDetailPanel.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default function KnowledgeDetailPanel({ system, nodes, concepts, models,
112112
}
113113

114114
const selectedNode = selection?.type === "node" ? nodes.find((n) => n.id === selection.id) ?? null : null;
115+
const selectedConcept = selection?.type === "concept" ? concepts.find((c) => c.id === selection.id) ?? null : null;
115116

116117
return (
117118
<div data-testid="detail-panel" style={{ width: 320, flexShrink: 0, borderLeft: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0, background: "#fff" }}>
@@ -161,6 +162,15 @@ export default function KnowledgeDetailPanel({ system, nodes, concepts, models,
161162
{CONCEPT_STATUSES.map((s) => <option key={s} value={s}>{conceptStatusLabel[s]}</option>)}
162163
</select>
163164
<SaveButton label={selection.id == null ? "创建概念" : "保存更改"} saving={saving} onClick={() => void saveConcept()} dataTestid="concept-save" />
165+
{/* 最近应用只读行(§五)——lastAppliedAt 有值→秒级时间,null→从未应用 */}
166+
{selection.id != null && (
167+
<div style={{ marginTop: 12 }}>
168+
<div style={label}>最近应用</div>
169+
<div data-testid="concept-last-applied" style={{ fontSize: 12, color: "#6b7280" }}>
170+
{selectedConcept?.lastAppliedAt != null ? formatSeconds(selectedConcept.lastAppliedAt) : "从未应用"}
171+
</div>
172+
</div>
173+
)}
164174
{selection.id != null && <KnowledgeLinkSection systemId={system.id} entityType="concept" entityId={selection.id} links={links} onChanged={onChanged} />}
165175
</div>
166176
)}
@@ -212,3 +222,10 @@ const input: React.CSSProperties = { width: "100%", fontSize: 13, padding: "6px
212222
const textarea: React.CSSProperties = { ...input, resize: "vertical", fontFamily: "inherit" };
213223
/** 下拉样式 */
214224
const select: React.CSSProperties = { width: "100%", fontSize: 12, padding: "5px 6px", border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box" };
225+
226+
/** 秒级格式化时间(Y-M-D H:m:s;"最近应用"只读行) */
227+
function formatSeconds(ms: number): string {
228+
const d = new Date(ms);
229+
const pad = (n: number) => String(n).padStart(2, "0");
230+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
231+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* ModelCardCreateDialog.test.tsx — 组侧「+ 概念卡」弹窗交互测试(v0.13.2 §六)。
4+
*
5+
* @ai-context: 覆盖 §五 组侧最小方案——name 必填拦截、提交 invoke 参数契约
6+
* (create_model_card groupId/name/essence/boundary/relation)、
7+
* 幂等返回提示(同组同名已存在则返回既有卡,成功提示说明不误导重复记账)。
8+
* invoke 全 mock(不触碰真实后端)。
9+
*/
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
12+
import type { Flashcard } from "../types";
13+
14+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
15+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
16+
17+
import ModelCardCreateDialog from "./ModelCardCreateDialog";
18+
19+
const createdFlashcard: Flashcard = {
20+
id: 10, groupId: 3, noteId: null, fragmentId: null,
21+
front: "安全边际", back: "本质:\n边界:\n联系:",
22+
kind: "model", stateJson: "{}", dueAt: 0, createdAt: 0,
23+
};
24+
25+
beforeEach(() => {
26+
invokeMock.mockReset();
27+
invokeMock.mockImplementation(async (cmd: string) => {
28+
if (cmd === "create_model_card") return createdFlashcard;
29+
throw new Error(`unexpected: ${cmd}`);
30+
});
31+
});
32+
33+
afterEach(() => cleanup());
34+
35+
function renderDialog(onCreated = vi.fn()) {
36+
return render(
37+
<ModelCardCreateDialog groupId={3} groupName="化妆课" onClose={vi.fn()} onCreated={onCreated} />,
38+
);
39+
}
40+
41+
describe("ModelCardCreateDialog 组侧概念卡", () => {
42+
it("渲染:名称 + 三问输入行 + 提交按钮齐全", () => {
43+
renderDialog();
44+
expect(screen.getByTestId("model-card-dialog")).toBeTruthy();
45+
expect(screen.getByTestId("model-card-name")).toBeTruthy();
46+
expect(screen.getByTestId("model-card-essence")).toBeTruthy();
47+
expect(screen.getByTestId("model-card-boundary")).toBeTruthy();
48+
expect(screen.getByTestId("model-card-relation")).toBeTruthy();
49+
expect(screen.getByTestId("model-card-submit")).toBeTruthy();
50+
});
51+
52+
it("必填校验:名称为空点提交 → 红字拦截且不调用 create_model_card", () => {
53+
renderDialog();
54+
fireEvent.click(screen.getByTestId("model-card-submit"));
55+
expect(screen.getByTestId("model-card-status").textContent).toContain("概念名不能为空");
56+
expect(invokeMock).not.toHaveBeenCalledWith("create_model_card", expect.anything());
57+
});
58+
59+
it("提交:按契约调用 create_model_card(groupId/name/essence/boundary/relation)", async () => {
60+
renderDialog();
61+
fireEvent.change(screen.getByTestId("model-card-name"), { target: { value: "安全边际" } });
62+
fireEvent.change(screen.getByTestId("model-card-essence"), { target: { value: "赔率领先" } });
63+
fireEvent.change(screen.getByTestId("model-card-boundary"), { target: { value: "不是分散仓位" } });
64+
fireEvent.change(screen.getByTestId("model-card-relation"), { target: { value: "与仓位相关" } });
65+
fireEvent.click(screen.getByTestId("model-card-submit"));
66+
await screen.findByTestId("model-card-status");
67+
expect(invokeMock).toHaveBeenCalledWith("create_model_card", {
68+
groupId: 3, name: "安全边际",
69+
essence: "赔率领先", boundary: "不是分散仓位", relation: "与仓位相关",
70+
});
71+
});
72+
73+
it("幂等返回:成功提示说明「已存在则返回既有卡」", async () => {
74+
renderDialog();
75+
fireEvent.change(screen.getByTestId("model-card-name"), { target: { value: "安全边际" } });
76+
fireEvent.click(screen.getByTestId("model-card-submit"));
77+
const msg = (await screen.findByTestId("model-card-status")).textContent ?? "";
78+
expect(msg).toContain("已创建概念卡");
79+
expect(msg).toContain("已存在则返回既有卡");
80+
});
81+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* ModelCardCreateDialog — 组侧「+ 概念卡」创建弹窗(v0.13.2 §五)。
3+
*
4+
* @ai-context: 双面体单向升格(§一)——model 卡(记忆面)只在组内创建,概念
5+
* (思辨面)在体系;本弹窗只在选定组内建卡,不建体系侧入口
6+
* (体系只引用、不收纳)。create_model_card 幂等:同组同名已有卡
7+
* 返回既有卡——成功提示明确说明"已存在则返回既有卡",不误导重复记账。
8+
* @ai-context: 不预填内容(预填=假燃料)——name 必填,三问(本质/边界/联系)
9+
* 全部可空;card 背面由后端按卡面契约 compose(§三),前端只提交字段。
10+
*/
11+
import { useState } from "react";
12+
import { invoke } from "@tauri-apps/api/core";
13+
import type { Flashcard } from "../types";
14+
15+
interface Props {
16+
/** 目标组 id(组仍是唯一容器——卡只在组内创建) */
17+
groupId: number;
18+
/** 组名(标题呈现用) */
19+
groupName: string;
20+
/** 关闭弹窗(遮罩/✕) */
21+
onClose: () => void;
22+
/** 创建成功后回调(父级可刷新/关闭;无组卡列表时可为空) */
23+
onCreated?: () => void;
24+
}
25+
26+
export default function ModelCardCreateDialog({ groupId, groupName, onClose, onCreated }: Props) {
27+
const [name, setName] = useState("");
28+
const [essence, setEssence] = useState("");
29+
const [boundary, setBoundary] = useState("");
30+
const [relation, setRelation] = useState("");
31+
const [busy, setBusy] = useState(false);
32+
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null);
33+
34+
const submit = async () => {
35+
const trimmed = name.trim();
36+
// 前端仅做非空拦截;name 归一化/≤2000 由 command 层校验
37+
if (!trimmed) { setStatus({ text: "概念名不能为空", error: true }); return; }
38+
setBusy(true); setStatus(null);
39+
try {
40+
await invoke<Flashcard>("create_model_card", {
41+
groupId,
42+
name: trimmed,
43+
essence: essence.trim() || null,
44+
boundary: boundary.trim() || null,
45+
relation: relation.trim() || null,
46+
});
47+
setStatus({ text: `已创建概念卡「${trimmed}」(已存在则返回既有卡)`, error: false });
48+
onCreated?.();
49+
} catch (e) {
50+
setStatus({ text: `创建失败: ${e}`, error: true });
51+
} finally {
52+
setBusy(false);
53+
}
54+
};
55+
56+
return (
57+
<div
58+
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" }}
59+
onClick={onClose}
60+
>
61+
<div
62+
data-testid="model-card-dialog"
63+
onClick={(e) => e.stopPropagation()}
64+
style={{ width: 380, maxWidth: "92vw", background: "#fff", borderRadius: 12, padding: 20, boxShadow: "0 12px 40px rgba(0,0,0,0.2)" }}
65+
>
66+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
67+
<span style={{ fontWeight: 600, fontSize: 14 }}>🧠 概念卡 · {groupName}</span>
68+
<button data-testid="model-card-cancel" onClick={onClose} style={{ marginLeft: "auto", cursor: "pointer", fontSize: 13 }}>✕ 关闭</button>
69+
</div>
70+
71+
<label style={label}>概念名 *</label>
72+
<input data-testid="model-card-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="卡住你的词/概念" style={input} />
73+
<label style={label}>本质(它"是"什么)</label>
74+
<textarea data-testid="model-card-essence" value={essence} onChange={(e) => setEssence(e.target.value)} rows={2} style={textarea} />
75+
<label style={label}>边界(它"不是"什么)</label>
76+
<textarea data-testid="model-card-boundary" value={boundary} onChange={(e) => setBoundary(e.target.value)} rows={2} style={textarea} />
77+
<label style={label}>联系(它和什么相关)</label>
78+
<textarea data-testid="model-card-relation" value={relation} onChange={(e) => setRelation(e.target.value)} rows={2} style={textarea} />
79+
80+
<button data-testid="model-card-submit" onClick={() => void submit()} disabled={busy} style={{ ...submitBtn, opacity: busy ? 0.6 : 1 }}>
81+
{busy ? "创建中…" : "创建概念卡"}
82+
</button>
83+
{status && <p data-testid="model-card-status" style={{ marginTop: 8, fontSize: 12, color: status.error ? "#dc2626" : "#0f766e" }}>{status.text}</p>}
84+
</div>
85+
</div>
86+
);
87+
}
88+
89+
/** 标签样式 */
90+
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 600, color: "#6b7280", margin: "8px 0 3px" };
91+
/** 输入样式 */
92+
const input: React.CSSProperties = { width: "100%", fontSize: 13, padding: "6px 8px", border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box" };
93+
/** 文本域样式 */
94+
const textarea: React.CSSProperties = { ...input, resize: "vertical", fontFamily: "inherit" };
95+
/** 提交按钮样式 */
96+
const submitBtn: React.CSSProperties = { marginTop: 12, width: "100%", fontSize: 13, cursor: "pointer", padding: "8px 0", borderRadius: 6, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e", fontWeight: 600 };

0 commit comments

Comments
 (0)