Skip to content

Commit c2d8a0f

Browse files
committed
feat(cards): 组级复习面与闪卡生成交互
1 parent d9dc8cd commit c2d8a0f

4 files changed

Lines changed: 234 additions & 2 deletions

File tree

‎app/src/components/NoteGroupPanel.tsx‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,19 @@ interface Props {
4646
selectedNoteId: number | null;
4747
/** 组/笔记变更后的刷新回调(NotesPage 重载笔记列表) */
4848
onChanged: () => void;
49+
/** v0.11.2:打开复习面(groupId=null 全量;groupName 呈现用) */
50+
onOpenReview: (groupId: number | null, groupName: string) => void;
4951
}
5052

51-
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged }: Props) {
53+
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged, onOpenReview }: Props) {
5254
const [groups, setGroups] = useState<NoteGroup[]>([]);
5355
const [expandedId, setExpandedId] = useState<number | null>(null);
5456
const [status, setStatus] = useState("");
5557
// v0.11.1:feed 捕获开关(默认关——功能预览纪律)与捕获输入态
5658
const [feedCaptureOn, setFeedCaptureOn] = useState(false);
5759
const [fragText, setFragText] = useState("");
60+
// v0.11.2:全量到期卡数(顶栏"复习 N"徽标)
61+
const [dueTotal, setDueTotal] = useState(0);
5862
// 改判表单态(展开区消费)
5963
const [overrideKind, setOverrideKind] = useState("standalone");
6064
const [overrideDomain, setOverrideDomain] = useState("");
@@ -63,6 +67,8 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
6367
try {
6468
const list = await invoke<NoteGroup[]>("list_note_groups", { terrain: null });
6569
setGroups(list);
70+
const due = await invoke<number>("count_due_cards", { groupId: null });
71+
setDueTotal(due);
6672
} catch (e) {
6773
setStatus(`组加载失败: ${e}`);
6874
}
@@ -138,11 +144,30 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
138144
}
139145
};
140146

147+
// ── v0.11.2:组→闪卡生成(本地规则版;幂等)──
148+
const runGenerateCards = async (g: NoteGroup) => {
149+
try {
150+
const n = await invoke<number>("generate_group_cards", { groupId: g.id });
151+
setStatus(n > 0 ? `已生成 ${n} 张闪卡` : "无新卡可生成(已生成过或无可出卡素材)");
152+
await load();
153+
} catch (e) {
154+
setStatus(`闪卡生成失败: ${e}`);
155+
}
156+
};
157+
141158
return (
142159
<div style={{ width: 230, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column" }}>
143160
<div style={{ padding: "10px 12px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
144161
<span>🗂 笔记组</span>
145-
<button onClick={() => void load()} style={{ marginLeft: "auto", fontSize: 13, cursor: "pointer" }} title="刷新组列表">⟳</button>
162+
{/* v0.11.2:全量复习入口(UI 最小化——只一个按钮 + 到期数) */}
163+
<button
164+
onClick={() => onOpenReview(null, "全部组")}
165+
style={{ marginLeft: "auto", fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #0f766e", background: dueTotal > 0 ? "#f0fdfa" : "#fff", color: "#0f766e" }}
166+
title="开始复习到期卡片"
167+
>
168+
🎴 复习{dueTotal > 0 ? ` ${dueTotal}` : ""}
169+
</button>
170+
<button onClick={() => void load()} style={{ fontSize: 13, cursor: "pointer" }} title="刷新组列表">⟳</button>
146171
</div>
147172
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
148173
{/* v0.11.1:碎片快速捕获区(开关默认关;开启后置顶——feed 进料口) */}
@@ -257,6 +282,16 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
257282
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}>
258283
✓ 改判
259284
</button>
285+
{/* v0.11.2:组→闪卡生成 + 组级复习(学习循环入口) */}
286+
<button onClick={() => void runGenerateCards(g)}
287+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}
288+
title="从组内笔记词汇表/碎片生成闪卡(幂等)">
289+
⚙ 生成闪卡
290+
</button>
291+
<button onClick={() => onOpenReview(g.id, g.name)}
292+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}>
293+
🎴 复习本组
294+
</button>
260295
{selectedNoteId != null && (
261296
<>
262297
<button onClick={() => void runMove(g.id)}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* ReviewSessionOverlay — 组级复习面(v0.11.2;UI 最小化——动机App化防御)。
3+
*
4+
* @ai-context: 提取优先为默认(P4/P18/P25/P30):先显示 front 线索 → 用户
5+
* 点击"回忆完成"→ 展开 back 验证 → 四档评分推进 FSRS 调度。
6+
* 无被动重读模式、无 streak 展示(弹性承诺——不追债不清零)。
7+
* @ai-context: 引擎先有真牙叙事后置——本面板只有卡片与评分,零叙事元素。
8+
*/
9+
import { useCallback, useEffect, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
import type { Flashcard } from "../types";
12+
13+
/** 四档评分按钮(文案=回忆质量自评) */
14+
const RATINGS: { value: string; label: string; color: string }[] = [
15+
{ value: "again", label: "忘了", color: "#dc2626" },
16+
{ value: "hard", label: "困难", color: "#d97706" },
17+
{ value: "good", label: "记得", color: "#059669" },
18+
{ value: "easy", label: "轻松", color: "#0284c7" },
19+
];
20+
21+
interface Props {
22+
/** 组过滤(null=全部到期卡) */
23+
groupId: number | null;
24+
groupName: string;
25+
onClose: () => void;
26+
}
27+
28+
export default function ReviewSessionOverlay({ groupId, groupName, onClose }: Props) {
29+
const [queue, setQueue] = useState<Flashcard[]>([]);
30+
const [index, setIndex] = useState(0);
31+
const [revealed, setRevealed] = useState(false);
32+
const [done, setDone] = useState(0);
33+
const [status, setStatus] = useState("");
34+
const [loaded, setLoaded] = useState(false);
35+
36+
// 加载到期队列(一次性——复习中评分出的卡不回插本队列,下次再现)
37+
const loadQueue = useCallback(async () => {
38+
try {
39+
const cards = await invoke<Flashcard[]>("list_due_cards", { groupId, limit: 50 });
40+
setQueue(cards);
41+
} catch (e) {
42+
setStatus(`复习队列加载失败: ${e}`);
43+
} finally {
44+
setLoaded(true);
45+
}
46+
}, [groupId]);
47+
48+
useEffect(() => { void loadQueue(); }, [loadQueue]);
49+
50+
// ESC 退出(复习中误触保护:只在未展开答案时直接退)
51+
useEffect(() => {
52+
const handler = (e: KeyboardEvent) => {
53+
if (e.key === "Escape") onClose();
54+
};
55+
window.addEventListener("keydown", handler);
56+
return () => window.removeEventListener("keydown", handler);
57+
}, [onClose]);
58+
59+
const current = queue[index] ?? null;
60+
61+
const rate = async (rating: string) => {
62+
if (!current) return;
63+
try {
64+
await invoke("review_card", { cardId: current.id, rating });
65+
setDone((d) => d + 1);
66+
setRevealed(false);
67+
setIndex((i) => i + 1);
68+
setStatus("");
69+
} catch (e) {
70+
setStatus(`评分失败: ${e}`);
71+
}
72+
};
73+
74+
const finished = loaded && (!current || index >= queue.length);
75+
76+
return (
77+
<div
78+
style={{
79+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000,
80+
display: "flex", alignItems: "center", justifyContent: "center",
81+
}}
82+
onClick={onClose}
83+
>
84+
<div
85+
onClick={(e) => e.stopPropagation()}
86+
style={{
87+
width: 520, maxWidth: "92vw", background: "#fff", borderRadius: 12,
88+
padding: 20, boxShadow: "0 12px 40px rgba(0,0,0,0.2)",
89+
}}
90+
>
91+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
92+
<span style={{ fontWeight: 600, fontSize: 14 }}>🎴 复习 · {groupName}</span>
93+
{current && <span style={{ fontSize: 12, color: "#9ca3af" }}>{index + 1}/{queue.length}</span>}
94+
<button onClick={onClose} style={{ marginLeft: "auto", cursor: "pointer", fontSize: 13 }}>✕ 退出</button>
95+
</div>
96+
97+
{!loaded && <p style={{ fontSize: 13, color: "#9ca3af" }}>加载中…</p>}
98+
99+
{finished && (
100+
<div style={{ textAlign: "center", padding: "24px 0" }}>
101+
<p style={{ fontSize: 15, fontWeight: 600 }}>
102+
{done > 0 ? `本轮复习完成:${done} 张卡片` : "当前没有到期卡片"}
103+
</p>
104+
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 6 }}>
105+
{done > 0 ? "间隔已由 FSRS 推进,到期后再来。" : "生成闪卡或等待卡片到期。"}
106+
</p>
107+
</div>
108+
)}
109+
110+
{current && !finished && (
111+
<div>
112+
{/* front(线索——先回忆再看) */}
113+
<div style={{
114+
minHeight: 90, padding: 16, background: "#f9fafb", borderRadius: 8,
115+
fontSize: 16, lineHeight: 1.6, whiteSpace: "pre-wrap",
116+
}}>
117+
{current.front}
118+
</div>
119+
{!revealed ? (
120+
<button
121+
onClick={() => setRevealed(true)}
122+
style={{
123+
width: "100%", marginTop: 12, padding: "10px 0", fontSize: 14,
124+
cursor: "pointer", background: "#0f766e", color: "#fff",
125+
border: "none", borderRadius: 8,
126+
}}
127+
>
128+
回忆完成 · 查看答案
129+
</button>
130+
) : (
131+
<>
132+
{/* back(验证材料) */}
133+
<div style={{
134+
marginTop: 10, minHeight: 70, padding: 14, background: "#f0fdfa",
135+
borderRadius: 8, fontSize: 13, lineHeight: 1.7, whiteSpace: "pre-wrap",
136+
maxHeight: 220, overflowY: "auto",
137+
}}>
138+
{current.back}
139+
</div>
140+
{/* 四档评分(提取质量自评 → FSRS 调度) */}
141+
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
142+
{RATINGS.map((r) => (
143+
<button
144+
key={r.value}
145+
onClick={() => void rate(r.value)}
146+
style={{
147+
flex: 1, padding: "8px 0", fontSize: 13, cursor: "pointer",
148+
border: `1px solid ${r.color}`, color: r.color,
149+
background: "#fff", borderRadius: 6,
150+
}}
151+
>
152+
{r.label}
153+
</button>
154+
))}
155+
</div>
156+
</>
157+
)}
158+
</div>
159+
)}
160+
{status && <p style={{ marginTop: 8, fontSize: 12, color: "#dc2626" }}>{status}</p>}
161+
</div>
162+
</div>
163+
);
164+
}

‎app/src/pages/NotesPage.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import NoteEditView from "../components/NoteEditView";
1717
import NoteListView, { parseTags } from "../components/NoteListView";
1818
import type { SortMode } from "../components/NoteListView";
1919
import NoteGroupPanel from "../components/NoteGroupPanel";
20+
import ReviewSessionOverlay from "../components/ReviewSessionOverlay";
2021
import NoteReadingView from "../components/NoteReadingView";
2122
import ImagePreviewOverlay from "../components/ImagePreviewOverlay";
2223
import VersionPanel from "../components/VersionPanel";
@@ -35,6 +36,8 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
3536
const [sortMode, setSortMode] = useState<SortMode>("updated-desc");
3637
// v0.11.0:组过滤(null=全部;NoteGroupPanel 受控)
3738
const [groupFilter, setGroupFilter] = useState<number | null>(null);
39+
// v0.11.2:复习面(groupId=null 全量;undefined=关闭)
40+
const [review, setReview] = useState<{ groupId: number | null; name: string } | undefined>(undefined);
3841
const [selected, setSelected] = useState<Note | null>(null);
3942
const [status, setStatus] = useState("");
4043
// M3:编辑态
@@ -225,6 +228,7 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
225228
onGroupFilterChange={setGroupFilter}
226229
selectedNoteId={selected?.id ?? null}
227230
onChanged={() => void load(keyword, tagFilter, sortMode)}
231+
onOpenReview={(groupId, name) => setReview({ groupId, name })}
228232
/>
229233
{/* ── 左栏:搜索 + 标签过滤 + 排序 + 列表 ── */}
230234
<NoteListView
@@ -280,6 +284,14 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
280284
{previewImg && (
281285
<ImagePreviewOverlay src={previewImg.src} title={previewImg.title} onClose={() => setPreviewImg(null)} />
282286
)}
287+
{/* v0.11.2:组级复习面(提取优先;评分推进 FSRS 调度) */}
288+
{review && (
289+
<ReviewSessionOverlay
290+
groupId={review.groupId}
291+
groupName={review.name}
292+
onClose={() => setReview(undefined)}
293+
/>
294+
)}
283295
</div>
284296
);
285297
}

‎app/src/types/notes.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,27 @@ export interface NoteGroup {
106106
updatedAt: number;
107107
}
108108

109+
// ────────────────────────────────────────────────────────────
110+
// 闪卡类型(v0.11.2 学习循环统一;Rust Flashcard camelCase 契约)
111+
// ────────────────────────────────────────────────────────────
112+
113+
/** 闪卡(提取优先:front 线索→回忆→back 验证→评分) */
114+
export interface Flashcard {
115+
id: number;
116+
groupId: number;
117+
noteId: number | null;
118+
fragmentId: number | null;
119+
front: string;
120+
back: string;
121+
/** fact 先做;action/model 预埋不做(N13) */
122+
kind: string;
123+
/** CardState JSON(后端调度契约,前端透传不解析) */
124+
stateJson: string;
125+
/** 到期时刻(Unix 毫秒) */
126+
dueAt: number;
127+
createdAt: number;
128+
}
129+
109130
// ────────────────────────────────────────────────────────────
110131
// 笔记版本类型(v0.8.0 M4,REQ-144/REQ-143 完整;Rust serde 契约)
111132
// ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)