Skip to content

Commit 50e39f9

Browse files
committed
feat(knowledge): 结算体系简报(触点③)
1 parent eed95fb commit 50e39f9

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

‎app/src/components/RouteInfoPopover.test.tsx‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,40 @@ const groupWithReason: NoteGroup = {
2222
routeOverridden: 0, noteCount: 5, createdAt: 0, updatedAt: 0,
2323
};
2424

25+
/** 共享默认 invoke 实现(beforeEach 挂载;用例覆写知识命令时委托回它) */
26+
let baseInvoke: (cmd: string, args?: { systemId?: number }) => Promise<unknown>;
27+
2528
beforeEach(() => {
2629
invokeMock.mockReset();
27-
invokeMock.mockImplementation(async (cmd: string) => {
30+
const impl = async (cmd: string, args?: { systemId?: number }) => {
2831
switch (cmd) {
2932
case "week_contract_status":
3033
return { contract: null, weekStart: 0, actualDays: 0, actualCards: 0, minimalDayMet: false };
3134
case "override_group_route":
3235
return true;
3336
case "generate_group_cards":
3437
return 2;
38+
case "settlement_plan":
39+
// 空结算计划(无重复/无可归档)
40+
return { itemCount: 0, due: false, lastSettledAt: null, mergePairs: [], archiveCandidates: [] };
41+
case "list_group_cards":
42+
// 无 model 卡 → 行 1 不出现(用例内按需覆写)
43+
return [];
44+
case "list_knowledge_systems":
45+
return [];
46+
case "list_knowledge_links":
47+
// 后端契约:强制 system_id(无全局查询)——按入参过滤
48+
return args?.systemId === 2
49+
? [{ id: 1, systemId: 2, nodeId: null, conceptId: null, modelId: null, targetType: "note_group", targetId: 3, createdAt: 0 }]
50+
: [];
51+
case "list_knowledge_concepts":
52+
return [];
3553
default:
3654
throw new Error(`unexpected: ${cmd}`);
3755
}
38-
});
56+
};
57+
baseInvoke = impl;
58+
invokeMock.mockImplementation(impl);
3959
});
4060

4161
afterEach(() => cleanup());
@@ -104,4 +124,32 @@ describe("RouteInfoPopover ⓘ 弹层", () => {
104124
fireEvent.keyDown(window, { key: "Escape" });
105125
expect(onClose).toHaveBeenCalled();
106126
});
127+
128+
it("结算简报:组内有 model 卡且体系有失效概念时两行提示", async () => {
129+
// 覆写知识命令为有数据版本(其余走共享默认);list_knowledge_links 按 system_id 过滤
130+
invokeMock.mockImplementation(async (cmd: string, args?: { systemId?: number }) => {
131+
switch (cmd) {
132+
case "list_group_cards":
133+
return [
134+
{ id: 1, groupId: 3, kind: "model", front: "曝光三角", back: "q", noteId: null, fragmentId: null, stateJson: "", createdAt: 0, updatedAt: 0, dueAt: 0 },
135+
];
136+
case "list_knowledge_systems":
137+
return [{ id: 2, parentSystemId: null, name: "摄影", kind: "domain", coreQuestion: null, status: "active", createdAt: 0, updatedAt: 0 }];
138+
case "list_knowledge_links":
139+
return args?.systemId === 2
140+
? [{ id: 1, systemId: 2, nodeId: null, conceptId: null, modelId: null, targetType: "note_group", targetId: 3, createdAt: 0 }]
141+
: [];
142+
case "list_knowledge_concepts":
143+
// last_applied_at = 200 天前 → stale(90 天未引用)
144+
return [{ id: 5, systemId: 2, name: "曝光三角", essence: "e", boundary: "b", relation: "r", status: "core", lastAppliedAt: Date.now() - 200 * 86400_000, createdAt: 0, updatedAt: 0 }];
145+
default:
146+
return baseInvoke(cmd, args);
147+
}
148+
});
149+
150+
renderPopover();
151+
fireEvent.click(await screen.findByTestId("settle-button"));
152+
expect(await screen.findByTestId("sys-brief-model")).toBeTruthy();
153+
expect(await screen.findByTestId("sys-brief-stale")).toBeTruthy();
154+
});
107155
});

‎app/src/components/RouteInfoPopover.tsx‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
* 结算/复习本组/移入移出选中笔记);④ 周契约卡(REQ-200)。
88
* @ai-context: 弹层由 GroupSidebar 锚定渲染(fixed 定位 + 透明背板关闭 +
99
* ESC 关闭);所有变更经 onChanged 通知 NotesPage 刷新列表。
10+
* @ai-context: v0.13.7 触点③ 结算体系简报——弹层挂载时并行拉取组内 model 卡
11+
* 计数与体系引用/概念失效态(90 天未应用);纯提示、加载失败静默
12+
* 降级(不阻塞结算区);list_knowledge_links 契约强制 system_id,
13+
* 按非归档体系逐个查询聚合。
1014
*/
11-
import { useEffect, useMemo, useState } from "react";
15+
import { useCallback, useEffect, useMemo, useState } from "react";
1216
import { invoke } from "@tauri-apps/api/core";
13-
import type { NoteGroup } from "../types";
17+
import type { Flashcard, NoteGroup } from "../types";
18+
import type { KnowledgeConcept, KnowledgeLink, KnowledgeSystem } from "../types/knowledge";
1419
import { humanRouteLine, parseRouteReason } from "../utils/routeReason";
1520
import WeekContractCard from "./WeekContractCard";
1621
import ModelCardCreateDialog from "./ModelCardCreateDialog";
@@ -67,6 +72,8 @@ export default function RouteInfoPopover({
6772
const [busy, setBusy] = useState(false);
6873
// v0.13.2:组侧「+ 概念卡」弹窗开合(组仍是唯一容器——只在此组内建卡)
6974
const [cardDialogOpen, setCardDialogOpen] = useState(false);
75+
// v0.13.7 触点③:结算体系简报(model 卡计数 + 关联体系概念失效数)
76+
const [sysBrief, setSysBrief] = useState<{ modelCount: number; staleConcepts: number; systemName: string } | null>(null);
7077

7178
const reason = useMemo(() => parseRouteReason(group.routeReason), [group.routeReason]);
7279

@@ -109,6 +116,39 @@ export default function RouteInfoPopover({
109116
}
110117
};
111118

119+
// ③ v0.13.7 触点③ 结算体系简报(弹层挂载时并行拉取;纯提示——失败静默降级)
120+
const loadSystemBrief = useCallback(async () => {
121+
try {
122+
const [cards, sysList] = await Promise.all([
123+
invoke<Flashcard[]>("list_group_cards", { groupId: group.id }),
124+
invoke<KnowledgeSystem[]>("list_knowledge_systems"),
125+
]);
126+
const modelCount = cards.filter((c) => c.kind === "model").length;
127+
// 后端 list_knowledge_links 强制 system_id——按非归档体系聚合
128+
const activeSystems = sysList.filter((s) => s.status !== "archived");
129+
const linkArrays = await Promise.all(
130+
activeSystems.map((s) => invoke<KnowledgeLink[]>("list_knowledge_links", { systemId: s.id })),
131+
);
132+
const links = linkArrays.flat();
133+
const groupLinks = links.filter((l) => l.targetType === "note_group" && l.targetId === group.id);
134+
if (modelCount === 0 && groupLinks.length === 0) { setSysBrief(null); return; }
135+
const sys = sysList.find((s) => s.id === groupLinks[0]?.systemId) ?? null;
136+
let staleConcepts = 0;
137+
if (sys) {
138+
const concepts = await invoke<KnowledgeConcept[]>("list_knowledge_concepts", { systemId: sys.id });
139+
const now = Date.now();
140+
staleConcepts = concepts.filter((c) => {
141+
const base = c.lastAppliedAt ?? c.createdAt;
142+
return now - base > 90 * 86400_000;
143+
}).length;
144+
}
145+
setSysBrief({ modelCount, staleConcepts, systemName: sys?.name ?? "" });
146+
} catch { setSysBrief(null); } // 简报为纯提示——加载失败静默降级
147+
}, [group.id]);
148+
149+
// 弹层挂载时触发(一次性;结算区展开时并行拉取)
150+
useEffect(() => { void loadSystemBrief(); }, [loadSystemBrief]);
151+
112152
// ③ 结算计划(呈现→确认→执行)
113153
const runSettlementPlan = async () => {
114154
setBusy(true);
@@ -238,7 +278,7 @@ export default function RouteInfoPopover({
238278
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
239279
<button onClick={() => void runGenerateCards()} disabled={busy} style={BTN} title="从组内笔记词汇表/碎片生成闪卡(幂等)">⚙ 生成闪卡</button>
240280
<button onClick={() => setCardDialogOpen(true)} data-testid="model-card-open" style={BTN} title="在组内建一张概念卡(模型卡)——组内记忆面">+ 概念卡</button>
241-
<button onClick={() => void runSettlementPlan()} disabled={busy} style={BTN} title="对账本组:提炼核心/合并重复/归档低价值">🧹 结算</button>
281+
<button onClick={() => void runSettlementPlan()} disabled={busy} data-testid="settle-button" style={BTN} title="对账本组:提炼核心/合并重复/归档低价值">🧹 结算</button>
242282
<button onClick={() => onOpenReview(group.id, group.name)} style={BTN}>🎴 复习本组</button>
243283
{selectedNoteId != null && (
244284
<>
@@ -270,6 +310,18 @@ export default function RouteInfoPopover({
270310
</div>
271311
</div>
272312
)}
313+
{/* v0.13.7 触点③ 结算体系简报(model 卡计数 + 体系概念失效) */}
314+
{sysBrief && (sysBrief.modelCount > 0 || sysBrief.staleConcepts > 0) && (
315+
<div data-testid="sys-brief" style={{ marginTop: 8, padding: 8, background: "#f0fdfa", borderRadius: 6, border: "1px solid #99f6e4" }}>
316+
<div style={{ fontWeight: 600, color: "#0f766e", marginBottom: 4 }}>体系简报</div>
317+
{sysBrief.modelCount > 0 && (
318+
<div data-testid="sys-brief-model" style={{ fontSize: 11, color: "#0f766e" }}>🧬 本组有 {sysBrief.modelCount} 张 model 卡可纳入体系(复习面「纳入体系」)</div>
319+
)}
320+
{sysBrief.staleConcepts > 0 && sysBrief.systemName && (
321+
<div data-testid="sys-brief-stale" style={{ fontSize: 11, color: "#b45309", marginTop: 2 }}>⏳ 体系「{sysBrief.systemName}」有 {sysBrief.staleConcepts} 个概念 90 天未引用</div>
322+
)}
323+
</div>
324+
)}
273325
</div>
274326

275327
{/* ④ 周契约卡(REQ-200 弹性承诺呈现层) */}

0 commit comments

Comments
 (0)