Skip to content

Commit 186fde9

Browse files
committed
feat(goal): v0.18.1 M2 前端——毕业仪式/回顾流/放弃/降级提示/毕业档案(REQ-255~257)
- GraduateDialog:报告预览(现算信号)→ 确认 → goal_settle 报告快照渲染 (里程碑/结算/复习/成果物四区);失败信息可读且不关闭 - RetroTimeline:四类时间线(创建→里程碑→结算→毕业)+ 毕业报告折叠块 - GoalDetail:毕业按钮 ready 门控(判据未满禁用+标题提示)/放弃内联确认 (可选原因)/组删除降级提示条/已毕业-删除目标前提示报告保留 - GoalsPage:「🎓 毕业档案」区——list_goal_graduations 快照永久可读 (目标删除后仍可见,验收 5);加载失败 console.warn 降级不阻断列表 - vitest:GraduateDialog 确认流 2 用例(509 全绿 + tsc 零错误)
1 parent 17e757d commit 186fde9

6 files changed

Lines changed: 453 additions & 5 deletions

File tree

‎app/src/components/GoalDetail.tsx‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { invoke } from "@tauri-apps/api/core";
1111
import type { GoalDetailView, GoalProgressView, GroupWeakness } from "../types/goals";
1212
import { GOAL_STATUS_LABELS } from "../types/goals";
1313
import InterviewDialog from "./InterviewDialog";
14+
import GraduateDialog from "./GraduateDialog";
15+
import RetroTimeline from "./RetroTimeline";
1416

1517
interface Props {
1618
goalId: number;
@@ -31,6 +33,10 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
3133
const [renameValue, setRenameValue] = useState("");
3234
const [editingMileId, setEditingMileId] = useState<number | null>(null);
3335
const [editingMileValue, setEditingMileValue] = useState("");
36+
// v0.18.1:毕业仪式对话框 / 放弃原因内联确认
37+
const [graduateOpen, setGraduateOpen] = useState(false);
38+
const [abandonMode, setAbandonMode] = useState(false);
39+
const [abandonReason, setAbandonReason] = useState("");
3440

3541
const refresh = useCallback(async () => {
3642
try {
@@ -91,12 +97,26 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
9197
setBindGroupId(0); onChanged(); await refresh();
9298
};
9399
const removeGoal = async () => {
94-
if (!window.confirm(`确定删除目标「${goal.name}」?里程碑与绑定将一并移除(组本身不受影响)。`)) return;
100+
if (goal.status === "graduated") {
101+
// 已毕业:删除仅影响目标本体——毕业报告快照永久保留(档案区可读)
102+
if (!window.confirm(`确定删除已毕业目标「${goal.name}」?毕业报告快照仍会在「毕业档案」保留。`)) return;
103+
} else if (!window.confirm(`确定删除目标「${goal.name}」?里程碑与绑定将一并移除(组本身不受影响)。`)) {
104+
return;
105+
}
95106
await invoke("delete_goal", { id: goalId });
96107
onDeleted();
97108
};
109+
const abandon = async () => {
110+
await invoke("goal_abandon", { id: goalId, reason: abandonReason.trim() || null });
111+
setAbandonMode(false);
112+
setAbandonReason("");
113+
onChanged(); await refresh();
114+
};
98115
const boundIds = detail.groups.map((g) => g.id);
99116
const bindable = groups.filter((g) => !boundIds.includes(g.id));
117+
// v0.18.1:组删除降级提示(group_settled 型里程碑的绑定组被删 → ref SET NULL)
118+
const degradedMilestones = milestones.filter((m) => m.criteriaType === "group_settled" && m.refGroupId == null);
119+
const canGraduate = ready && goal.status === "active";
100120

101121
return (
102122
<div data-testid="goal-detail" style={{ padding: 16, overflow: "auto", height: "100%", boxSizing: "border-box" }}>
@@ -151,6 +171,13 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
151171
</div>
152172
)}
153173

174+
{/* 组删除降级提示(REQ-257):绑定组已删——判据信号丢失,按手动确认 */}
175+
{degradedMilestones.length > 0 && (
176+
<div data-testid="degraded-milestone-note" style={{ fontSize: 11, color: "#b45309", background: "#fffbeb", borderRadius: 6, padding: "6px 10px", margin: "8px 0" }}>
177+
⚠️ 绑定组已删除({degradedMilestones.length} 条「随组结算」里程碑失去自动通过信号——现按手动确认)
178+
</div>
179+
)}
180+
154181
{/* 里程碑 */}
155182
<SectionTitle>里程碑(结算型随组结算自动通过)</SectionTitle>
156183
{milestones.map((m) => (
@@ -222,14 +249,48 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
222249
)}
223250

224251
{/* 动作区 */}
225-
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
252+
<div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
226253
<button onClick={() => void changeStatus(goal.status === "paused" ? "active" : "paused")} style={miniPrimary}>
227254
{goal.status === "paused" ? "▶ 恢复" : "⏸ 暂停"}
228255
</button>
229256
<button onClick={() => setEditing(true)} style={miniPrimary}>✎ 重新访谈</button>
230-
<button disabled style={{ ...miniDanger, opacity: 0.5 }} title="毕业仪式与回顾流随 v0.18.1(M2)交付">🎓 毕业仪式 · M2</button>
231-
<button onClick={() => void removeGoal()} style={miniDanger} title="删除目标(里程碑/绑定一并移除)">🗑 删除目标</button>
257+
{goal.status === "active" || goal.status === "paused" ? (
258+
<>
259+
<button
260+
data-testid="graduate-open"
261+
onClick={() => setGraduateOpen(true)}
262+
disabled={!canGraduate}
263+
style={{ ...miniPrimary, opacity: canGraduate ? 1 : 0.5 }}
264+
title={canGraduate ? "毕业仪式——确认后生成报告快照" : "毕业判据未全部满足(见上方明细)"}
265+
>
266+
🎓 毕业仪式
267+
</button>
268+
{abandonMode ? (
269+
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
270+
<input
271+
data-testid="abandon-reason"
272+
value={abandonReason}
273+
onChange={(e) => setAbandonReason(e.target.value)}
274+
placeholder="放弃原因(可选)"
275+
style={{ fontSize: 11, padding: "3px 8px", border: "1px solid #d1d5db", borderRadius: 4, width: 140 }}
276+
/>
277+
<button data-testid="abandon-confirm" onClick={() => void abandon()} style={miniDanger}>确认放弃</button>
278+
<button onClick={() => { setAbandonMode(false); setAbandonReason(""); }} style={miniPrimary}>取消</button>
279+
</div>
280+
) : (
281+
<button data-testid="abandon-open" onClick={() => setAbandonMode(true)} style={miniDanger}>🗑 放弃</button>
282+
)}
283+
</>
284+
) : (
285+
<span style={{ fontSize: 11, color: "#9ca3af" }}>{goal.status === "graduated" ? "已毕业——回顾流与报告见下方" : "已放弃——无惩罚,随时可再立新目标"}</span>
286+
)}
287+
<button onClick={() => void removeGoal()} style={miniDanger} title="删除目标(里程碑/绑定一并移除;毕业报告快照保留)">🗑 删除目标</button>
232288
</div>
289+
290+
{/* v0.18.1:回顾流 + 毕业报告(快照永久保留) */}
291+
<SectionTitle>回顾流(创建 → 里程碑 → 结算 → 毕业)</SectionTitle>
292+
<RetroTimeline goalId={goalId} />
293+
233294
{err && <p style={{ fontSize: 11, color: "#dc2626", marginTop: 8 }}>{err}</p>}
234295
{editing && (
235296
<InterviewDialog
@@ -240,6 +301,13 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
240301
onCreated={() => { setEditing(false); onChanged(); void refresh(); }}
241302
/>
242303
)}
304+
{graduateOpen && (
305+
<GraduateDialog
306+
goalId={goalId}
307+
onClose={() => setGraduateOpen(false)}
308+
onGraduated={() => { setGraduateOpen(false); onChanged(); void refresh(); }}
309+
/>
310+
)}
243311
</div>
244312
);
245313
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* GraduateDialog.test.tsx — 毕业仪式确认流测试(v0.18.1 验收 5)。
4+
*
5+
* @ai-context: 确认前呈现现算信号预览;确认调用 goal_settle 并渲染报告快照
6+
* 结果(里程碑/结算/复习统计/成果物);失败信息可读。
7+
*/
8+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
9+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
10+
import GraduateDialog from "./GraduateDialog";
11+
import type { GraduationReport } from "../types/goals";
12+
13+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
14+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
15+
16+
const report: GraduationReport = {
17+
goalId: 10,
18+
goalName: "学会 Python",
19+
graduatedAt: 1753000000,
20+
milestones: [
21+
{ title: "基础入门", status: "done", completedAt: 100 },
22+
{ title: "应用练习", status: "skipped", completedAt: null },
23+
],
24+
groupSettlements: [{ groupId: 3, groupName: "Python 组", settlementCount: 2, lastSettledAt: 200 }],
25+
reviewStats: { cardTotal: 40, reviewLogsTotal: 120, reviewDays90: 7, weakCards: 6 },
26+
artifacts: { groups: 1, notes: 8, cards: 40, concepts: 5 },
27+
criteriaStatement: "完成全部里程碑 + 组结算 1 次",
28+
};
29+
30+
beforeEach(() => {
31+
invokeMock.mockReset();
32+
invokeMock.mockImplementation(async (cmd: string) => {
33+
if (cmd === "get_goal_detail") {
34+
return {
35+
goal: { id: 10, name: "学会 Python", domainTag: null, status: "active", horizonEnd: null, successCriteriaJson: "{}", intentJson: "{}", createdAt: 0, completedAt: null, updatedAt: 0 },
36+
criteria: [{ label: "里程碑", met: true, detail: "1 / 1 已完成" }, { label: "组结算", met: true, detail: "2 / 1 次" }],
37+
progress: { progress: { milestoneTotal: 1, milestoneDone: 1, percent: 100, settlementsCount: 2, contractDone: 0, contractTotal: 0, reviewDays90: 7, applicationsCount: 0, selfTestPassedRate: null, weakGroups: [] }, statement: "100% · 里程碑 1/1", ready: true, checks: [] },
38+
milestones: [{ id: 1, goalId: 10, title: "基础入门", dueAt: null, orderIdx: 0, status: "done", criteriaType: "manual", refGroupId: null, completedAt: 100, createdAt: 0 }],
39+
groups: [{ id: 3, name: "Python 组" }],
40+
declaration: "用3 个月学会 Python,达成标准:…",
41+
};
42+
}
43+
if (cmd === "get_goal_progress") {
44+
return { progress: { milestoneTotal: 1, milestoneDone: 1, percent: 100, settlementsCount: 2, contractDone: 0, contractTotal: 0, reviewDays90: 7, applicationsCount: 0, selfTestPassedRate: null, weakGroups: [] }, statement: "100% · 里程碑 1/1", ready: true, checks: [{ label: "里程碑", met: true, detail: "1 / 1 已完成" }] };
45+
}
46+
if (cmd === "goal_settle") return report;
47+
throw new Error(`unexpected: ${cmd}`);
48+
});
49+
});
50+
51+
afterEach(() => cleanup());
52+
53+
describe("GraduateDialog 毕业仪式", () => {
54+
it("确认调用 goal_settle 并渲染报告快照(里程碑/结算/复习/成果物)", async () => {
55+
const onGraduated = vi.fn();
56+
render(<GraduateDialog goalId={10} onClose={vi.fn()} onGraduated={onGraduated} />);
57+
await screen.findByTestId("confirm-graduate");
58+
fireEvent.click(screen.getByTestId("confirm-graduate"));
59+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("goal_settle", { id: 10 }));
60+
await screen.findByTestId("graduate-result");
61+
expect(screen.getByText(/已毕业——「学会 Python」/)).toBeTruthy();
62+
expect(screen.getByText(/完成全部里程碑 \+ 组结算 1 次/)).toBeTruthy();
63+
expect(screen.getByText(/Python 组:2 次/)).toBeTruthy();
64+
expect(screen.getByText(/40 卡 · 120 次复习/)).toBeTruthy();
65+
expect(screen.getByText(/8 笔记 · 40 卡 · 5 概念/)).toBeTruthy();
66+
expect(onGraduated).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it("确认失败:错误信息展示且不关闭(回退再等等)", async () => {
70+
invokeMock.mockImplementation(async (cmd: string) => {
71+
if (cmd === "get_goal_detail" || cmd === "get_goal_progress") throw new Error("x");
72+
if (cmd === "goal_settle") throw new Error("毕业判据未全部满足:里程碑(0 / 1)");
73+
throw new Error(`unexpected: ${cmd}`);
74+
});
75+
const onGraduated = vi.fn();
76+
render(<GraduateDialog goalId={10} onClose={vi.fn()} onGraduated={onGraduated} />);
77+
// 预览加载失败但确认按钮仍在(信号明细缺失不阻断确认流——后端守卫兜底)
78+
const btn = await screen.findByTestId("confirm-graduate");
79+
fireEvent.click(btn);
80+
await waitFor(() => expect(screen.getByTestId("graduate-error").textContent).toContain("毕业判据未全部满足"));
81+
expect(onGraduated).not.toHaveBeenCalled();
82+
});
83+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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

Comments
 (0)