Skip to content

Commit 1ac11ba

Browse files
committed
feat(tasks): REQ-299/300 练习与问题清单轻量面
1 parent 8d314b0 commit 1ac11ba

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

‎app/src/components/ActionCenterOverlay.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { useCallback, useEffect, useRef, useState } from "react";
1313
import { invoke } from "@tauri-apps/api/core";
1414
import SopRunOverlay from "./SopRunOverlay";
15+
import { PracticeOverlay, QuestionsOverlay } from "./PracticeQuestionsOverlays";
1516

1617
interface SopTemplateView {
1718
id: number;
@@ -98,6 +99,9 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
9899
const [templates, setTemplates] = useState<SopTemplateView[]>([]);
99100
const [notes, setNotes] = useState<{ id: number; title: string }[]>([]);
100101
const [activeTemplate, setActiveTemplate] = useState<SopTemplateView | null>(null);
102+
// v0.20.3(REQ-299/300):练习/问题轻量面(嵌套 Overlay)
103+
const [showPractice, setShowPractice] = useState(false);
104+
const [showQuestions, setShowQuestions] = useState(false);
101105
const [sopNoteId, setSopNoteId] = useState<number | null>(null);
102106
const [sopName, setSopName] = useState("");
103107
const [sopStart, setSopStart] = useState("0");
@@ -346,6 +350,14 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
346350
</button>
347351
))}
348352
</div>
353+
<div style={{ marginLeft: 12, display: "flex", gap: 4 }}>
354+
<button style={{ ...btn, borderRadius: 6, border: "1px solid #7c3aed", background: "#f5f3ff", color: "#6d28d9" }} onClick={() => setShowPractice(true)}>
355+
🎯 练习
356+
</button>
357+
<button style={{ ...btn, borderRadius: 6, border: "1px solid #2563eb", background: "#eff6ff", color: "#1d4ed8" }} onClick={() => setShowQuestions(true)}>
358+
❓ 问题
359+
</button>
360+
</div>
349361
<button style={{ ...btn, marginLeft: "auto" }} onClick={onClose}>关闭</button>
350362
</div>
351363

@@ -451,6 +463,9 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
451463
onChanged={() => { void loadSop(); onChanged?.(); }}
452464
/>
453465
)}
466+
{/* v0.20.3(REQ-299/300):练习/问题轻量面 */}
467+
{showPractice && <PracticeOverlay onClose={() => setShowPractice(false)} />}
468+
{showQuestions && <QuestionsOverlay onClose={() => setShowQuestions(false)} />}
454469
</div>
455470
</div>
456471
);
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* PracticeQuestionsOverlays — 🎯 练习条目 & ❓ 问题清单轻量面(v0.20.3 / REQ-299/300)。
3+
*
4+
* @ai-context: 与行动中心同遮罩形态的嵌套 Overlay(打开即用,零新导航):
5+
* 练习=内建习得行动(打点入完成史 practice_tick,宽容缺勤);
6+
* 问题=Me 问题化(open/answered/archived + 答沉淀回链——供输出创作
7+
* 前翻看与复盘)。
8+
*/
9+
import { useCallback, useEffect, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
12+
interface PracticeView {
13+
id: number;
14+
note_id: number | null;
15+
kb_concept_id: number | null;
16+
text: string;
17+
frequency: string;
18+
goal: string | null;
19+
mastery: number | null;
20+
next_due: number | null;
21+
status: string;
22+
}
23+
interface QuestionView {
24+
id: number;
25+
note_id: number | null;
26+
kb_concept_id: number | null;
27+
text: string;
28+
context: string | null;
29+
status: string;
30+
answer_ref: string | null;
31+
}
32+
33+
const overlayStyle: React.CSSProperties = {
34+
position: "fixed",
35+
inset: 0,
36+
background: "rgba(0,0,0,.45)",
37+
zIndex: 1150,
38+
display: "flex",
39+
alignItems: "center",
40+
justifyContent: "center",
41+
};
42+
const cardStyle: React.CSSProperties = {
43+
background: "#fff",
44+
borderRadius: 12,
45+
width: 560,
46+
maxWidth: "92vw",
47+
maxHeight: "80vh",
48+
overflow: "auto",
49+
padding: 16,
50+
fontSize: 13,
51+
};
52+
const btn: React.CSSProperties = { padding: "4px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
53+
const okBtn: React.CSSProperties = { ...btn, background: "#0d9488", color: "#fff", border: "none" };
54+
const ghostBtn: React.CSSProperties = { ...btn, background: "#fff", border: "1px solid #e5e7eb", color: "#374151" };
55+
56+
function shell(title: string, subtitle: string, onClose: () => void, children: React.ReactNode) {
57+
return (
58+
<div style={overlayStyle} onClick={onClose}>
59+
<div style={cardStyle} onClick={(e) => e.stopPropagation()}>
60+
<div style={{ display: "flex", alignItems: "center", marginBottom: 10, gap: 8 }}>
61+
<h3 style={{ margin: 0, fontSize: 15 }}>{title}</h3>
62+
<span style={{ fontSize: 11, color: "#9ca3af" }}>{subtitle}</span>
63+
<button style={{ ...ghostBtn, marginLeft: "auto" }} onClick={onClose}>关闭</button>
64+
</div>
65+
{children}
66+
</div>
67+
</div>
68+
);
69+
}
70+
71+
export function PracticeOverlay({ onClose }: { onClose: () => void }) {
72+
const [items, setItems] = useState<PracticeView[]>([]);
73+
const [text, setText] = useState("");
74+
const [freq, setFreq] = useState<"daily" | "manual">("manual");
75+
const [err, setErr] = useState("");
76+
const [msg, setMsg] = useState("");
77+
78+
const reload = useCallback(async () => {
79+
try {
80+
setErr("");
81+
setItems(await invoke<PracticeView[]>("practice_list", { status: "active" }));
82+
} catch (e) {
83+
setErr(String(e));
84+
}
85+
}, []);
86+
87+
useEffect(() => {
88+
void reload();
89+
}, [reload]);
90+
91+
const create = async () => {
92+
if (!text.trim()) return;
93+
try {
94+
await invoke("practice_create", { text: text.trim(), frequency: freq, goal: null });
95+
setText("");
96+
await reload();
97+
} catch (e) {
98+
setErr(String(e));
99+
}
100+
};
101+
102+
const tick = async (item: PracticeView) => {
103+
try {
104+
await invoke("practice_tick", { itemId: item.id, mastery: null });
105+
setMsg(`🎯 已打点:${item.text}(daily 型推到明天;manual 型不造伪死线)`);
106+
await reload();
107+
} catch (e) {
108+
setErr(String(e));
109+
}
110+
};
111+
112+
const dueCount = items.filter((i) => i.next_due != null && i.next_due <= Math.floor(Date.now() / 1000)).length;
113+
114+
return shell("🎯 练习条目", `该练了 ${dueCount} · 闪卡之外第二条复利曲线(宽容缺勤只记史)`, onClose, (
115+
<>
116+
{msg && <div style={{ fontSize: 11, color: "#047857", marginBottom: 6 }}>{msg}</div>}
117+
{err && <div style={{ fontSize: 11, color: "#dc2626", marginBottom: 6 }}>{err}</div>}
118+
<div style={{ display: "flex", gap: 6, marginBottom: 10 }}>
119+
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="练习什么?(如:哑铃弯举 3×12)" style={{ flex: 1, fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 4, padding: "4px 6px" }} />
120+
<select value={freq} onChange={(e) => setFreq(e.target.value as "daily" | "manual")} style={{ fontSize: 12 }}>
121+
<option value="daily">daily(每日)</option>
122+
<option value="manual">manual(手动)</option>
123+
</select>
124+
<button style={okBtn} onClick={() => void create()}>新建</button>
125+
</div>
126+
{items.length === 0 ? (
127+
<p style={{ color: "#9ca3af", fontSize: 12 }}>暂无练习条目——把「需要周期练」的行动内建为练习条目</p>
128+
) : (
129+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
130+
{items.map((it) => (
131+
<div key={it.id} style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "6px 10px", display: "flex", gap: 6, alignItems: "center" }}>
132+
<span style={{ fontSize: 12.5, flex: 1 }}>{it.text}</span>
133+
<span style={{ fontSize: 10.5, color: "#9ca3af" }}>
134+
{it.frequency === "daily" ? "每日" : "手动"}
135+
{it.mastery != null ? ` · 熟练 ${it.mastery}/5` : ""}
136+
{it.next_due != null ? ` · 下次 ${new Date(it.next_due * 1000).toLocaleDateString()}` : ""}
137+
</span>
138+
<button style={okBtn} onClick={() => void tick(it)}>🎯 打点</button>
139+
</div>
140+
))}
141+
</div>
142+
)}
143+
<p style={{ fontSize: 11, color: "#9ca3af", marginTop: 10 }}>打点入完成史(practice_tick)——练习曲线由史聚合;缺勤不追债不清零。</p>
144+
</>
145+
));
146+
}
147+
148+
export function QuestionsOverlay({ onClose }: { onClose: () => void }) {
149+
const [items, setItems] = useState<QuestionView[]>([]);
150+
const [text, setText] = useState("");
151+
const [err, setErr] = useState("");
152+
const [msg, setMsg] = useState("");
153+
const [answerFor, setAnswerFor] = useState<number | null>(null);
154+
const [answerRef, setAnswerRef] = useState("");
155+
156+
const reload = useCallback(async () => {
157+
try {
158+
setErr("");
159+
const all = await invoke<QuestionView[]>("question_list", { status: null });
160+
setItems(all);
161+
} catch (e) {
162+
setErr(String(e));
163+
}
164+
}, []);
165+
166+
useEffect(() => {
167+
void reload();
168+
}, [reload]);
169+
170+
const create = async () => {
171+
if (!text.trim()) return;
172+
try {
173+
await invoke("question_create", { text: text.trim(), noteId: null, context: null });
174+
setText("");
175+
await reload();
176+
} catch (e) {
177+
setErr(String(e));
178+
}
179+
};
180+
181+
const answer = async (q: QuestionView) => {
182+
try {
183+
await invoke("question_answer", { id: q.id, answerRef: answerRef.trim() || null });
184+
setAnswerFor(null);
185+
setAnswerRef("");
186+
setMsg("✓ 已标记已答(答沉淀处回链已记)");
187+
await reload();
188+
} catch (e) {
189+
setErr(String(e));
190+
}
191+
};
192+
193+
const archive = async (q: QuestionView) => {
194+
try {
195+
await invoke("question_set_status", { id: q.id, status: "archived" });
196+
await reload();
197+
} catch (e) {
198+
setErr(String(e));
199+
}
200+
};
201+
202+
const open = items.filter((q) => q.status === "open");
203+
const answered = items.filter((q) => q.status === "answered");
204+
205+
return shell("❓ 问题清单", "Me 洞见问题化——open/answered/archived(输出创作前翻看/复盘原料)", onClose, (
206+
<>
207+
{msg && <div style={{ fontSize: 11, color: "#047857", marginBottom: 6 }}>{msg}</div>}
208+
{err && <div style={{ fontSize: 11, color: "#dc2626", marginBottom: 6 }}>{err}</div>}
209+
<div style={{ display: "flex", gap: 6, marginBottom: 10 }}>
210+
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="记下一个值得回答的问题…" style={{ flex: 1, fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 4, padding: "4px 6px" }} />
211+
<button style={okBtn} onClick={() => void create()}>添加</button>
212+
</div>
213+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
214+
{open.map((q) => (
215+
<div key={q.id} style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "6px 10px" }}>
216+
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
217+
<span style={{ fontSize: 12.5, flex: 1 }}>❓ {q.text}</span>
218+
<button style={ghostBtn} onClick={() => setAnswerFor(answerFor === q.id ? null : q.id)}>已答</button>
219+
<button style={{ ...ghostBtn, color: "#9ca3af" }} onClick={() => void archive(q)}>归档</button>
220+
</div>
221+
{answerFor === q.id && (
222+
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
223+
<input autoFocus value={answerRef} onChange={(e) => setAnswerRef(e.target.value)} placeholder="答沉淀处回链(笔记 id/卡 id,可空)" style={{ flex: 1, fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 4, padding: "2px 6px" }} />
224+
<button style={okBtn} onClick={() => void answer(q)}>确认</button>
225+
</div>
226+
)}
227+
</div>
228+
))}
229+
{open.length === 0 && answered.length === 0 && (
230+
<p style={{ color: "#9ca3af", fontSize: 12 }}>暂无问题——学习中的疑问随手记下,答沉淀后归档(可转复习卡出口规划中)</p>
231+
)}
232+
</div>
233+
{answered.length > 0 && (
234+
<>
235+
<h4 style={{ fontSize: 12, margin: "10px 0 4px", color: "#0f766e" }}>已答({answered.length})</h4>
236+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
237+
{answered.map((q) => (
238+
<div key={q.id} style={{ fontSize: 12, color: "#6b7280", display: "flex", gap: 6 }}>
239+
<span>✓ {q.text}</span>
240+
{q.answer_ref && <span style={{ fontSize: 10.5, color: "#9ca3af" }}>→ {q.answer_ref}</span>}
241+
</div>
242+
))}
243+
</div>
244+
</>
245+
)}
246+
</>
247+
));
248+
}

0 commit comments

Comments
 (0)