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