Skip to content

Commit e3a3c6e

Browse files
committed
feat(goal): 目标页 UI——GoalPage 单行卡/InterviewDialog 双展开/GoalDetail 弱项块(REQ-249~250)
- 独立「🎯 目标」Tab:单行卡列表(名称/状态徽标/一句话进度/🎓可毕业)+ 详情两栏 - InterviewDialog 一套组件双展开:访谈=四步向导+宣言确认(第 1/3 问必答、2/4 问 折叠可跳过);快速=名称+期限(判据走默认档);宣言页里程碑草案预填可删改 - GoalDetail:里程碑勾选/关联组 N:M 绑定解绑/判据明细/FSRS 低稳定性弱项块/ 暂停恢复/重新访谈(配方重推)/删除;毕业仪式按钮留壳(M2) - 空态领域热词推荐;零叙事元素(无 streak/排名/激励话术) - Vitest:InterviewDialog 流程(双展开/必答拦截/跳过/草案删改/创建契约)+ GoalCard 状态徽标(11 用例)
1 parent ba14c30 commit e3a3c6e

10 files changed

Lines changed: 1274 additions & 1 deletion

File tree

‎app/src/App.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ import ChatPage from "./pages/ChatPage";
2222
import SettingsPage from "./pages/SettingsPage";
2323
// v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不入每日复习面)
2424
import KnowledgePage from "./pages/KnowledgePage";
25+
// v0.18.0:学习目标页(意图层——独立 Tab,零叙事元素)
26+
import GoalsPage from "./pages/GoalsPage";
2527
import AppErrorBoundary from "./components/AppErrorBoundary";
2628
import CaptureFloatPanel from "./components/CaptureFloatPanel";
2729
import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
2830
// v0.16.1:浏览器痕迹去除(原生右键菜单抑制 + 文本输入应用内右键小菜单)
2931
import BrowserChrome from "./components/BrowserChrome";
3032
import type { AiTaskState } from "./types";
3133

32-
type Page = "classroom" | "sessions" | "notes" | "chat" | "knowledge" | "settings";
34+
type Page = "classroom" | "sessions" | "notes" | "chat" | "knowledge" | "goals" | "settings";
3335

3436
const NAV_ITEMS: { key: Page; label: string }[] = [
3537
{ key: "classroom", label: "📡 课堂助手" },
@@ -38,6 +40,8 @@ const NAV_ITEMS: { key: Page; label: string }[] = [
3840
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图)
3941
{ key: "chat", label: "💬 AI 对话" },
4042
{ key: "knowledge", label: "🧠 体系" },
43+
// v0.18.0:学习目标(意图层——目标=组的容器,N:M 绑定)
44+
{ key: "goals", label: "🎯 目标" },
4145
{ key: "settings", label: "⚙ 设置" },
4246
];
4347

@@ -328,6 +332,10 @@ function App() {
328332
onOpenGroup={(id) => { setFocusGroupId(id); setPage("notes"); }}
329333
/>
330334
</div>
335+
<div style={{ flex: 1, display: page === "goals" ? "block" : "none", overflow: "hidden" }}>
336+
{/* v0.18.0:学习目标页(意图层——列表是导航不是仪表盘) */}
337+
<GoalsPage />
338+
</div>
331339
<div style={{ flex: 1, display: page === "settings" ? "block" : "none", overflow: "hidden" }}>
332340
{/* 2026-08-21:设置页(保留挂载——面板状态不因切页重置;TD-004 同模式) */}
333341
<SettingsPage />
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* GoalCard.test.tsx — 目标卡状态徽标测试(v0.18.0 验收 2:单行折叠 + 徽标)。
4+
*/
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { cleanup, render, screen } from "@testing-library/react";
7+
import GoalCard from "./GoalCard";
8+
import type { Goal, GoalCardView } from "../types/goals";
9+
10+
function card(partial: Partial<Goal> & { id: number }, ready = false): GoalCardView {
11+
const goal: Goal = {
12+
name: "学会 Python", domainTag: null, status: "active", horizonEnd: null,
13+
successCriteriaJson: "{}", intentJson: "{}", createdAt: 0, completedAt: null,
14+
updatedAt: 0, ...partial,
15+
};
16+
return { goal, statement: "50% · 里程碑 1/2", percent: 50, milestoneDone: 1, milestoneTotal: 2, ready };
17+
}
18+
19+
afterEach(() => cleanup());
20+
21+
describe("GoalCard 状态徽标", () => {
22+
it("进行中:显示状态徽标 + 一句话进度,无可毕业徽标", () => {
23+
render(<GoalCard card={card({ id: 1 }, false)} onClick={() => {}} />);
24+
expect(screen.getByTestId("goal-status-badge").textContent).toBe("进行中");
25+
expect(screen.getByText("50% · 里程碑 1/2")).toBeTruthy();
26+
expect(screen.queryByTestId("goal-ready-badge")).toBeNull();
27+
});
28+
29+
it("可毕业:active + ready → 🎓 徽标亮起", () => {
30+
render(<GoalCard card={card({ id: 2 }, true)} onClick={() => {}} />);
31+
expect(screen.getByTestId("goal-ready-badge").textContent).toContain("可毕业");
32+
});
33+
34+
it("已毕业:状态徽标 + 完成日期,不显示可毕业", () => {
35+
const c = card({ id: 3, status: "graduated", completedAt: 1753084800 }, true);
36+
render(<GoalCard card={c} onClick={() => {}} />);
37+
expect(screen.getByTestId("goal-status-badge").textContent).toBe("已毕业");
38+
expect(screen.getByText("2025-07-21")).toBeTruthy();
39+
expect(screen.queryByTestId("goal-ready-badge")).toBeNull();
40+
});
41+
42+
it("已暂停与已放弃:文案正确(零叙事元素——无排名/无激励话术)", () => {
43+
const { unmount } = render(<GoalCard card={card({ id: 4, status: "paused" })} onClick={() => {}} />);
44+
expect(screen.getByTestId("goal-status-badge").textContent).toBe("已暂停");
45+
unmount();
46+
render(<GoalCard card={card({ id: 5, status: "abandoned" })} onClick={() => {}} />);
47+
expect(screen.getByTestId("goal-status-badge").textContent).toBe("已放弃");
48+
});
49+
50+
it("点击回调:整卡可点(列表是导航)", () => {
51+
let clicked = false;
52+
render(<GoalCard card={card({ id: 6 })} onClick={() => { clicked = true; }} />);
53+
screen.getByTestId("goal-card").click();
54+
expect(clicked).toBe(true);
55+
});
56+
});

‎app/src/components/GoalCard.tsx‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* GoalCard — 目标卡(单行折叠;优化评审 #5:列表是导航不是仪表盘)。
3+
*
4+
* @ai-context: 单行只呈现名称 + 状态徽标 + 一句话进度;周契约/组徽标/回顾流/
5+
* 操作全部收进目标详情。🎓 可毕业 = 判据配方全达标(后端现算)。
6+
* @ai-context: 零叙事元素——无活跃天数/排名/激励文案(规格 §十红线)。
7+
*/
8+
import type { GoalCardView } from "../types/goals";
9+
import { GOAL_STATUS_LABELS } from "../types/goals";
10+
11+
interface Props {
12+
card: GoalCardView;
13+
onClick: () => void;
14+
}
15+
16+
/** 状态徽标色(B 视觉系统四级颜色的最小子集) */
17+
function statusColor(status: string): { bg: string; fg: string } {
18+
switch (status) {
19+
case "active": return { bg: "#f0fdfa", fg: "#0f766e" };
20+
case "paused": return { bg: "#fffbeb", fg: "#b45309" };
21+
case "graduated": return { bg: "#ecfdf5", fg: "#047857" };
22+
default: return { bg: "#f3f4f6", fg: "#6b7280" };
23+
}
24+
}
25+
26+
export default function GoalCard({ card, onClick }: Props) {
27+
const badge = statusColor(card.goal.status);
28+
return (
29+
<div
30+
data-testid="goal-card"
31+
onClick={onClick}
32+
style={{
33+
display: "flex", alignItems: "center", gap: 10, padding: "10px 14px",
34+
background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8,
35+
cursor: "pointer", marginBottom: 8,
36+
}}
37+
>
38+
<span style={{ fontSize: 15 }}>🎯</span>
39+
<span style={{ fontWeight: 600, fontSize: 13, color: "#1f2937" }}>{card.goal.name}</span>
40+
<span
41+
data-testid="goal-status-badge"
42+
style={{ fontSize: 10, color: badge.fg, background: badge.bg, borderRadius: 8, padding: "1px 7px", border: `1px solid ${badge.fg}33` }}
43+
>
44+
{GOAL_STATUS_LABELS[card.goal.status] ?? card.goal.status}
45+
</span>
46+
{card.goal.status === "graduated" && card.goal.completedAt != null && (
47+
<span style={{ fontSize: 10, color: "#9ca3af" }}>
48+
{new Date(card.goal.completedAt * 1000).toISOString().slice(0, 10)}
49+
</span>
50+
)}
51+
{card.ready && card.goal.status === "active" && (
52+
<span data-testid="goal-ready-badge" style={{ fontSize: 10, color: "#b45309", background: "#fffbeb", borderRadius: 8, padding: "1px 7px" }}>
53+
🎓 可毕业
54+
</span>
55+
)}
56+
<span style={{ marginLeft: "auto", fontSize: 11, color: "#6b7280" }}>{card.statement}</span>
57+
</div>
58+
);
59+
}

‎app/src/components/GoalDetail.tsx‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* GoalDetail — 目标详情(里程碑清单/关联组/判据明细/弱项块/动作区)。
3+
*
4+
* @ai-context: 一致性契约——进度信号每次现算(get_goal_progress),动作后
5+
* 局部刷新;毕业仪式/回顾流属 M2(按钮留壳禁用不误导)。
6+
* @ai-context: 弱项块(M1)= FSRS 低稳定性卡占比 Top 组(json_extract 现算);
7+
* 里程碑勾选 done 记 goal_milestone_done 埋点(后端幂等)。
8+
*/
9+
import { useCallback, useEffect, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
import type { GoalDetailView, GoalProgressView, GroupWeakness } from "../types/goals";
12+
import { GOAL_STATUS_LABELS } from "../types/goals";
13+
import InterviewDialog from "./InterviewDialog";
14+
15+
interface Props {
16+
goalId: number;
17+
onChanged: () => void;
18+
onDeleted: () => void;
19+
}
20+
21+
export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
22+
const [detail, setDetail] = useState<GoalDetailView | null>(null);
23+
const [progress, setProgress] = useState<GoalProgressView | null>(null);
24+
const [err, setErr] = useState("");
25+
const [newMile, setNewMile] = useState("");
26+
const [bindGroupId, setBindGroupId] = useState(0);
27+
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
28+
const [editing, setEditing] = useState(false);
29+
30+
const refresh = useCallback(async () => {
31+
try {
32+
const d = await invoke<GoalDetailView>("get_goal_detail", { id: goalId });
33+
setDetail(d);
34+
const p = await invoke<GoalProgressView>("get_goal_progress", { id: goalId });
35+
setProgress(p);
36+
setErr("");
37+
} catch (e) {
38+
setErr(`详情加载失败: ${e}`);
39+
}
40+
}, [goalId]);
41+
42+
useEffect(() => { void refresh(); }, [refresh]);
43+
useEffect(() => {
44+
invoke<{ id: number; name: string }[]>("list_note_groups", { terrain: "container" })
45+
.then((gs) => setGroups(gs))
46+
.catch((e) => setErr(`组列表加载失败: ${e}`));
47+
}, []);
48+
49+
if (err && !detail) return <div style={{ padding: 24, fontSize: 13, color: "#dc2626" }}>{err}</div>;
50+
if (!detail) return <div style={{ padding: 24, fontSize: 13, color: "#9ca3af" }}>加载中…</div>;
51+
52+
const { goal, milestones, criteria } = detail;
53+
const p = progress?.progress;
54+
const ready = progress?.ready ?? false;
55+
56+
const toggleMilestone = async (m: { id: number; status: string }) => {
57+
await invoke("set_goal_milestone_status", { id: m.id, status: m.status === "done" ? "pending" : "done" });
58+
onChanged(); void refresh();
59+
};
60+
const addMilestone = async () => {
61+
if (!newMile.trim()) return;
62+
await invoke("add_goal_milestone", { goalId, title: newMile.trim(), dueAt: null, criteriaType: null, refGroupId: null });
63+
setNewMile("");
64+
onChanged(); await refresh();
65+
};
66+
const removeMilestone = async (id: number) => { await invoke("delete_goal_milestone", { id }); onChanged(); await refresh(); };
67+
const changeStatus = async (status: string) => { await invoke("update_goal_status", { id: goalId, status }); onChanged(); await refresh(); };
68+
const removeGroup = async (gid: number) => { await invoke("unbind_goal_group", { goalId, groupId: gid }); onChanged(); await refresh(); };
69+
const bindGroup = async () => {
70+
if (!bindGroupId) return;
71+
await invoke("bind_goal_group", { goalId, groupId: bindGroupId });
72+
setBindGroupId(0); onChanged(); await refresh();
73+
};
74+
const removeGoal = async () => {
75+
if (!window.confirm(`确定删除目标「${goal.name}」?里程碑与绑定将一并移除(组本身不受影响)。`)) return;
76+
await invoke("delete_goal", { id: goalId });
77+
onDeleted();
78+
};
79+
const boundIds = detail.groups.map((g) => g.id);
80+
const bindable = groups.filter((g) => !boundIds.includes(g.id));
81+
82+
return (
83+
<div data-testid="goal-detail" style={{ padding: 16, overflow: "auto", height: "100%", boxSizing: "border-box" }}>
84+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
85+
<span style={{ fontWeight: 700, fontSize: 15 }}>🎯 {goal.name}</span>
86+
<span style={{ fontSize: 11, color: "#6b7280" }}>{GOAL_STATUS_LABELS[goal.status]}</span>
87+
{ready && <span style={{ fontSize: 11, color: "#b45309", background: "#fffbeb", borderRadius: 8, padding: "1px 8px" }}>🎓 可毕业(毕业仪式 M2)</span>}
88+
<span style={{ marginLeft: "auto", fontSize: 11, color: "#9ca3af" }}>始于 {new Date(goal.createdAt * 1000).toISOString().slice(0, 10)}</span>
89+
</div>
90+
<p style={{ fontSize: 12, color: "#4b5563", background: "#fafaf9", padding: 8, borderRadius: 6, margin: "10px 0" }}>{detail.declaration}</p>
91+
92+
{/* 判据与进度 */}
93+
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 4 }}>
94+
{progress?.statement ?? ""} · 结算 {p?.settlementsCount ?? 0} 次 · 周契约 {p?.contractDone ?? 0}/{p?.contractTotal ?? 0} · 复习活跃 {p?.reviewDays90 ?? 0} 天 · 应用 {p?.applicationsCount ?? 0} 条
95+
</div>
96+
<div style={{ background: "#f3f4f6", borderRadius: 3, marginBottom: 10 }}>
97+
<div style={{ width: `${Math.min(100, Math.round(progress?.progress.percent ?? 0))}%`, height: 6, background: "#0f766e", borderRadius: 3 }} />
98+
</div>
99+
{criteria.length > 0 && (
100+
<div style={{ marginBottom: 10 }}>
101+
{criteria.map((c, i) => (
102+
<div key={i} style={{ fontSize: 11, color: c.met ? "#047857" : "#9ca3af" }}>
103+
{c.met ? "✓" : "○"} {c.label}:{c.detail}
104+
</div>
105+
))}
106+
</div>
107+
)}
108+
109+
{/* 里程碑 */}
110+
<SectionTitle>里程碑(结算型随组结算自动通过)</SectionTitle>
111+
{milestones.map((m) => (
112+
<div key={m.id} style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}>
113+
<input type="checkbox" checked={m.status === "done"} onChange={() => void toggleMilestone(m)} />
114+
<span style={{ fontSize: 12, color: m.status === "done" ? "#9ca3af" : "#1f2937", textDecoration: m.status === "done" ? "line-through" : "none" }}>{m.title}</span>
115+
{m.criteriaType === "group_settled" && <span style={{ fontSize: 10, color: "#0f766e", background: "#f0fdfa", borderRadius: 8, padding: "0 6px" }}>随组结算</span>}
116+
{m.status === "skipped" && <span style={{ fontSize: 10, color: "#9ca3af" }}>已跳过</span>}
117+
<button onClick={() => void removeMilestone(m.id)} style={smallDanger}>删</button>
118+
</div>
119+
))}
120+
<div style={{ display: "flex", gap: 6, margin: "6px 0 14px" }}>
121+
<input value={newMile} onChange={(e) => setNewMile(e.target.value)} placeholder="新里程碑(例如:第 8 周 刷题)" style={miniInput} />
122+
<button onClick={() => void addMilestone()} style={miniPrimary}>+</button>
123+
</div>
124+
125+
{/* 关联组 */}
126+
<SectionTitle>关联组(一组可服务多目标)</SectionTitle>
127+
<div style={{ marginBottom: 8 }}>
128+
{detail.groups.map((g) => (
129+
<span key={g.id} style={{ fontSize: 11, background: "#f3f4f6", borderRadius: 10, padding: "2px 8px", marginRight: 6, display: "inline-flex", alignItems: "center", gap: 4 }}>
130+
{g.name}
131+
<button onClick={() => void removeGroup(g.id)} style={smallDanger}>×</button>
132+
</span>
133+
))}
134+
{detail.groups.length === 0 && <span style={{ fontSize: 11, color: "#9ca3af" }}>暂无绑定——绑定后结算/复习/弱项才计入进度</span>}
135+
</div>
136+
{bindable.length > 0 && (
137+
<div style={{ display: "flex", gap: 6, marginBottom: 14 }}>
138+
<select value={bindGroupId} onChange={(e) => setBindGroupId(Number(e.target.value))} style={miniInput}>
139+
<option value={0}>选择组…</option>
140+
{bindable.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
141+
</select>
142+
<button onClick={() => void bindGroup()} style={miniPrimary}>+ 绑定</button>
143+
</div>
144+
)}
145+
146+
{/* 弱项块(M1:FSRS 低稳定性卡占比 Top 组) */}
147+
<SectionTitle>最弱一块(FSRS 低稳定性卡占比)</SectionTitle>
148+
{p && p.weakGroups.length > 0 ? (
149+
p.weakGroups.map((w: GroupWeakness) => (
150+
<div key={w.groupId} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
151+
<span style={{ fontSize: 11, width: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{w.groupName}</span>
152+
<div style={{ flex: 1, background: "#f3f4f6", borderRadius: 3 }}>
153+
<div style={{ width: `${Math.round(w.weakRatio * 100)}%`, height: 6, background: w.weakRatio >= 0.6 ? "#dc2626" : "#f59e0b", borderRadius: 3 }} />
154+
</div>
155+
<span style={{ fontSize: 10, color: "#6b7280" }}>{w.weakCards}/{w.cardTotal} 卡</span>
156+
</div>
157+
))
158+
) : (
159+
<p style={{ fontSize: 11, color: "#9ca3af", margin: "0 0 10px" }}>暂无弱项信号(绑定组无卡)</p>
160+
)}
161+
162+
{/* 动作区 */}
163+
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
164+
<button onClick={() => void changeStatus(goal.status === "paused" ? "active" : "paused")} style={miniPrimary}>
165+
{goal.status === "paused" ? "▶ 恢复" : "⏸ 暂停"}
166+
</button>
167+
<button onClick={() => setEditing(true)} style={miniPrimary}>✎ 重新访谈</button>
168+
<button disabled style={{ ...miniDanger, opacity: 0.5 }} title="毕业仪式与回顾流随 v0.18.1(M2)交付">🎓 毕业仪式 · M2</button>
169+
<button onClick={() => void removeGoal()} style={miniDanger} title="删除目标(里程碑/绑定一并移除)">🗑 删除目标</button>
170+
</div>
171+
{err && <p style={{ fontSize: 11, color: "#dc2626", marginTop: 8 }}>{err}</p>}
172+
{editing && (
173+
<InterviewDialog
174+
mode="interview"
175+
goalId={goalId}
176+
groups={groups}
177+
onClose={() => setEditing(false)}
178+
onCreated={() => { setEditing(false); onChanged(); void refresh(); }}
179+
/>
180+
)}
181+
</div>
182+
);
183+
}
184+
185+
function SectionTitle({ children }: { children: string }) {
186+
return <div style={{ fontWeight: 600, fontSize: 12, color: "#374151", margin: "8px 0 6px" }}>{children}</div>;
187+
}
188+
189+
const miniInput: React.CSSProperties = { fontSize: 11, padding: "4px 8px", border: "1px solid #e5e7eb", borderRadius: 4, flex: 1, minWidth: 0 };
190+
const miniPrimary: React.CSSProperties = { fontSize: 11, padding: "4px 10px", borderRadius: 4, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e", cursor: "pointer" };
191+
const miniDanger: React.CSSProperties = { fontSize: 11, padding: "4px 10px", borderRadius: 4, border: "1px solid #fecaca", background: "#fff", color: "#b91c1c", cursor: "pointer" };
192+
const smallDanger: React.CSSProperties = { fontSize: 10, padding: "1px 6px", border: "none", background: "none", color: "#b91c1c", cursor: "pointer" };

0 commit comments

Comments
 (0)