|
| 1 | +/** |
| 2 | + * GraduateDialog — 毕业仪式(报告预览 → 确认毕业)。 |
| 3 | + * |
| 4 | + * @ai-context: 毕业=用户可见确认仪式(v0.11.3 结算纪律延续)——确认前展示 |
| 5 | + * 现算信号预览(里程碑/结算/复习弱项),确认后 goal_settle 生成 |
| 6 | + * 完整报告快照(毕业后冻结、目标删除仍可读——REQ-255/256)。 |
| 7 | + * @ai-context: 未达标时按钮不可达(GoalDetail 禁用);本对话框只管确认流。 |
| 8 | + */ |
| 9 | +import { useCallback, useEffect, useState } from "react"; |
| 10 | +import { invoke } from "@tauri-apps/api/core"; |
| 11 | +import type { GraduationReport, GoalDetailView, GoalProgressView } from "../types/goals"; |
| 12 | + |
| 13 | +interface Props { |
| 14 | + goalId: number; |
| 15 | + onClose: () => void; |
| 16 | + onGraduated: () => void; |
| 17 | +} |
| 18 | + |
| 19 | +export default function GraduateDialog({ goalId, onClose, onGraduated }: Props) { |
| 20 | + const [detail, setDetail] = useState<GoalDetailView | null>(null); |
| 21 | + const [progress, setProgress] = useState<GoalProgressView | null>(null); |
| 22 | + const [report, setReport] = useState<GraduationReport | null>(null); |
| 23 | + const [err, setErr] = useState(""); |
| 24 | + const [saving, setSaving] = useState(false); |
| 25 | + |
| 26 | + const load = useCallback(async () => { |
| 27 | + try { |
| 28 | + const d = await invoke<GoalDetailView>("get_goal_detail", { id: goalId }); |
| 29 | + setDetail(d); |
| 30 | + const p = await invoke<GoalProgressView>("get_goal_progress", { id: goalId }); |
| 31 | + setProgress(p); |
| 32 | + setErr(""); |
| 33 | + } catch (e) { |
| 34 | + setErr(`加载失败: ${e}`); |
| 35 | + } |
| 36 | + }, [goalId]); |
| 37 | + |
| 38 | + useEffect(() => { void load(); }, [load]); |
| 39 | + |
| 40 | + const confirm = async () => { |
| 41 | + setSaving(true); |
| 42 | + setErr(""); |
| 43 | + try { |
| 44 | + const r = await invoke<GraduationReport>("goal_settle", { id: goalId }); |
| 45 | + setReport(r); |
| 46 | + onGraduated(); |
| 47 | + } catch (e) { |
| 48 | + setErr(`毕业失败: ${e}`); |
| 49 | + } finally { |
| 50 | + setSaving(false); |
| 51 | + } |
| 52 | + }; |
| 53 | + |
| 54 | + const p = progress?.progress; |
| 55 | + return ( |
| 56 | + <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 60 }}> |
| 57 | + <div data-testid="graduate-dialog" style={{ width: 520, maxHeight: "86vh", overflow: "auto", background: "#fff", borderRadius: 10, padding: 18, boxSizing: "border-box" }}> |
| 58 | + <div style={{ display: "flex", alignItems: "center", marginBottom: 8 }}> |
| 59 | + <span style={{ fontWeight: 700, fontSize: 15 }}>🎓 毕业仪式</span> |
| 60 | + <button onClick={onClose} style={{ marginLeft: "auto", border: "none", background: "none", fontSize: 14, cursor: "pointer", color: "#9ca3af" }}>✕</button> |
| 61 | + </div> |
| 62 | + <p style={{ fontSize: 12, color: "#6b7280", margin: "0 0 10px" }}> |
| 63 | + 毕业=确认这一轮学习目标达成——报告快照永久保留(目标删除后仍可读)。 |
| 64 | + </p> |
| 65 | + |
| 66 | + {report ? ( |
| 67 | + <div data-testid="graduate-result"> |
| 68 | + <div style={{ fontSize: 14, fontWeight: 700, color: "#047857", marginBottom: 8 }}>🎉 已毕业——「{report.goalName}」</div> |
| 69 | + <ReportBody report={report} /> |
| 70 | + <button onClick={onClose} style={primaryBtn}>完成</button> |
| 71 | + </div> |
| 72 | + ) : ( |
| 73 | + <> |
| 74 | + {/* 确认前:现算信号预览 */} |
| 75 | + {detail && p && ( |
| 76 | + <div style={{ fontSize: 12, color: "#374151", background: "#fafaf9", padding: 10, borderRadius: 6, marginBottom: 8, lineHeight: 2 }}> |
| 77 | + 里程碑 {p.milestoneDone}/{p.milestoneTotal} · 组结算 {p.settlementsCount} 次 · 复习活跃 {p.reviewDays90} 天 · 弱项 {p.weakGroups.length} 组 |
| 78 | + <div style={{ fontSize: 11, color: "#9ca3af" }}>确认后将生成完整报告:里程碑明细/子组结算/复习统计/成果物清单(组·笔记·闪卡·概念)</div> |
| 79 | + </div> |
| 80 | + )} |
| 81 | + <div style={{ fontSize: 11, color: "#6b7280", marginBottom: 8 }}> |
| 82 | + {detail?.criteria.map((c, i) => ( |
| 83 | + <div key={i} style={{ color: c.met ? "#047857" : "#9ca3af" }}>{c.met ? "✓" : "○"} {c.label}:{c.detail}</div> |
| 84 | + ))} |
| 85 | + </div> |
| 86 | + {err && <p data-testid="graduate-error" style={{ fontSize: 11, color: "#dc2626" }}>{err}</p>} |
| 87 | + <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> |
| 88 | + <button onClick={onClose} style={ghostBtn}>再等等</button> |
| 89 | + <button data-testid="confirm-graduate" onClick={() => void confirm()} disabled={saving} style={primaryBtn}> |
| 90 | + {saving ? "毕业中…" : "🎓 确认毕业"} |
| 91 | + </button> |
| 92 | + </div> |
| 93 | + </> |
| 94 | + )} |
| 95 | + </div> |
| 96 | + </div> |
| 97 | + ); |
| 98 | +} |
| 99 | + |
| 100 | +/** 报告正文(确认后结果 / 毕业档案共用) */ |
| 101 | +export function ReportBody({ report }: { report: GraduationReport }) { |
| 102 | + const done = report.milestones.filter((m) => m.status === "done").length; |
| 103 | + const total = report.milestones.filter((m) => m.status !== "skipped").length; |
| 104 | + const sumSettlements = report.groupSettlements.reduce((a, s) => a + s.settlementCount, 0); |
| 105 | + return ( |
| 106 | + <div style={{ fontSize: 12, color: "#374151" }}> |
| 107 | + <Section>达成标准</Section> |
| 108 | + <div style={{ marginBottom: 8 }}>{report.criteriaStatement}</div> |
| 109 | + <Section>里程碑</Section> |
| 110 | + <div style={{ marginBottom: 8 }}> |
| 111 | + {report.milestones.map((m, i) => ( |
| 112 | + <div key={i}>{m.status === "skipped" ? "○ 跳过" : m.status === "done" ? "✓" : "○"} {m.title}</div> |
| 113 | + ))} |
| 114 | + <div style={{ color: "#9ca3af" }}>{done}/{total} 达成</div> |
| 115 | + </div> |
| 116 | + <Section>组结算</Section> |
| 117 | + <div style={{ marginBottom: 8 }}> |
| 118 | + {report.groupSettlements.map((s, i) => ( |
| 119 | + <div key={i}>{s.groupName}:{s.settlementCount} 次(最近 {s.lastSettledAt ? new Date(s.lastSettledAt * 1000).toISOString().slice(0, 10) : "—"})</div> |
| 120 | + ))} |
| 121 | + <div style={{ color: "#9ca3af" }}>共 {sumSettlements} 次(含归档组历史)</div> |
| 122 | + </div> |
| 123 | + <Section>复习统计</Section> |
| 124 | + <div style={{ marginBottom: 8 }}> |
| 125 | + {report.reviewStats.cardTotal} 卡 · {report.reviewStats.reviewLogsTotal} 次复习 · 近 90 天 {report.reviewStats.reviewDays90} 天 · 低稳定性 {report.reviewStats.weakCards} 卡 |
| 126 | + </div> |
| 127 | + <Section>成果物</Section> |
| 128 | + <div> |
| 129 | + {report.artifacts.groups} 组 · {report.artifacts.notes} 笔记 · {report.artifacts.cards} 卡 · {report.artifacts.concepts} 概念 |
| 130 | + </div> |
| 131 | + </div> |
| 132 | + ); |
| 133 | +} |
| 134 | + |
| 135 | +function Section({ children }: { children: string }) { |
| 136 | + return <div style={{ fontWeight: 600, color: "#374151", marginTop: 8 }}>{children}</div>; |
| 137 | +} |
| 138 | + |
| 139 | +const primaryBtn: React.CSSProperties = { fontSize: 12, padding: "6px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #0f766e", background: "#0f766e", color: "#fff" }; |
| 140 | +const ghostBtn: React.CSSProperties = { fontSize: 12, padding: "6px 12px", borderRadius: 6, cursor: "pointer", border: "1px solid #d1d5db", background: "#fff", color: "#4b5563" }; |
0 commit comments