Skip to content

Commit 57c4dd7

Browse files
committed
fix(goal): 前端审查修复——访谈时限接线/改名与里程碑编辑接入/热词预填/预览对齐
- 逻辑 P1(审查定位):访谈模式第 3 问时限 chips(a.horizon)创建时丢失——create 面板默认 horizon 覆盖选择;改 effectiveHorizon 口径 + 测试断言(6m 透传) - 接入性 P2:update_goal/update_goal_milestone 两命令未接线——详情页改名(不传 horizon=不改变锚点)+ 里程碑标题行内编辑 - 逻辑 P2:宣言预览与后端 assemble_declaration 对齐——无期限长期目标文案 + horizonLabel 冗余三元清理 + 第0周显示修复 - 规范 P3:InterviewDialog onCreated 伪 Goal 对象清理(改为无参回调) - UX P3:空态领域热词点击预填目标名称(initialName)
1 parent 60f4d1e commit 57c4dd7

6 files changed

Lines changed: 108 additions & 30 deletions

File tree

‎app/src/components/GoalDetail.tsx‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
2626
const [bindGroupId, setBindGroupId] = useState(0);
2727
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
2828
const [editing, setEditing] = useState(false);
29+
// 改名(update_goal 接线)与里程碑标题行内编辑(update_goal_milestone 接线)
30+
const [renameMode, setRenameMode] = useState(false);
31+
const [renameValue, setRenameValue] = useState("");
32+
const [editingMileId, setEditingMileId] = useState<number | null>(null);
33+
const [editingMileValue, setEditingMileValue] = useState("");
2934

3035
const refresh = useCallback(async () => {
3136
try {
@@ -57,6 +62,20 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
5762
await invoke("set_goal_milestone_status", { id: m.id, status: m.status === "done" ? "pending" : "done" });
5863
onChanged(); void refresh();
5964
};
65+
const saveRename = async () => {
66+
if (!renameValue.trim()) { setRenameMode(false); return; }
67+
// horizon 不传 → 后端保持原锚点(update_goal_inner None=不变语义)
68+
await invoke("update_goal", { id: goalId, name: renameValue.trim(), domainTag: detail!.goal.domainTag, horizon: null });
69+
setRenameMode(false);
70+
onChanged(); await refresh();
71+
};
72+
const saveMileTitle = async (id: number) => {
73+
const title = editingMileValue.trim();
74+
if (!title) { setEditingMileId(null); return; }
75+
await invoke("update_goal_milestone", { id, title, dueAt: null });
76+
setEditingMileId(null);
77+
onChanged(); await refresh();
78+
};
6079
const addMilestone = async () => {
6180
if (!newMile.trim()) return;
6281
await invoke("add_goal_milestone", { goalId, title: newMile.trim(), dueAt: null, criteriaType: null, refGroupId: null });
@@ -82,10 +101,36 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
82101
return (
83102
<div data-testid="goal-detail" style={{ padding: 16, overflow: "auto", height: "100%", boxSizing: "border-box" }}>
84103
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
85-
<span style={{ fontWeight: 700, fontSize: 15 }}>🎯 {goal.name}</span>
104+
{renameMode ? (
105+
<input
106+
data-testid="rename-input"
107+
value={renameValue}
108+
onChange={(e) => setRenameValue(e.target.value)}
109+
autoFocus
110+
style={{ fontSize: 14, padding: "3px 8px", border: "1px solid #0f766e", borderRadius: 6, flex: 1 }}
111+
onKeyDown={(e) => { if (e.key === "Enter") void saveRename(); }}
112+
/>
113+
) : (
114+
<span style={{ fontWeight: 700, fontSize: 15 }}>🎯 {goal.name}</span>
115+
)}
86116
<span style={{ fontSize: 11, color: "#6b7280" }}>{GOAL_STATUS_LABELS[goal.status]}</span>
87117
{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>
118+
{renameMode ? (
119+
<>
120+
<button data-testid="rename-save" onClick={() => void saveRename()} style={miniPrimary}>✓</button>
121+
<button onClick={() => setRenameMode(false)} style={miniDanger}>取消</button>
122+
</>
123+
) : (
124+
<button
125+
data-testid="rename-start"
126+
onClick={() => { setRenameValue(goal.name); setRenameMode(true); }}
127+
style={{ marginLeft: "auto", ...miniPrimary }}
128+
title="改名(不改判据/绑组)"
129+
>
130+
✎ 改名
131+
</button>
132+
)}
133+
<span style={{ fontSize: 11, color: "#9ca3af" }}>始于 {new Date(goal.createdAt * 1000).toISOString().slice(0, 10)}</span>
89134
</div>
90135
<p style={{ fontSize: 12, color: "#4b5563", background: "#fafaf9", padding: 8, borderRadius: 6, margin: "10px 0" }}>{detail.declaration}</p>
91136

@@ -111,9 +156,26 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
111156
{milestones.map((m) => (
112157
<div key={m.id} style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}>
113158
<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>}
159+
{editingMileId === m.id ? (
160+
<>
161+
<input
162+
data-testid="mile-title-input"
163+
value={editingMileValue}
164+
onChange={(e) => setEditingMileValue(e.target.value)}
165+
autoFocus
166+
style={{ flex: 1, fontSize: 12, padding: "2px 6px", border: "1px solid #0f766e", borderRadius: 4 }}
167+
onKeyDown={(e) => { if (e.key === "Enter") void saveMileTitle(m.id); }}
168+
/>
169+
<button onClick={() => void saveMileTitle(m.id)} style={miniPrimary}>✓</button>
170+
</>
171+
) : (
172+
<>
173+
<span style={{ fontSize: 12, color: m.status === "done" ? "#9ca3af" : "#1f2937", textDecoration: m.status === "done" ? "line-through" : "none" }}>{m.title}</span>
174+
{m.criteriaType === "group_settled" && <span style={{ fontSize: 10, color: "#0f766e", background: "#f0fdfa", borderRadius: 8, padding: "0 6px" }}>随组结算</span>}
175+
{m.status === "skipped" && <span style={{ fontSize: 10, color: "#9ca3af" }}>已跳过</span>}
176+
<button onClick={() => { setEditingMileId(m.id); setEditingMileValue(m.title); }} style={smallGhost}>改</button>
177+
</>
178+
)}
117179
<button onClick={() => void removeMilestone(m.id)} style={smallDanger}>删</button>
118180
</div>
119181
))}
@@ -190,3 +252,4 @@ const miniInput: React.CSSProperties = { fontSize: 11, padding: "4px 8px", borde
190252
const miniPrimary: React.CSSProperties = { fontSize: 11, padding: "4px 10px", borderRadius: 4, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e", cursor: "pointer" };
191253
const miniDanger: React.CSSProperties = { fontSize: 11, padding: "4px 10px", borderRadius: 4, border: "1px solid #fecaca", background: "#fff", color: "#b91c1c", cursor: "pointer" };
192254
const smallDanger: React.CSSProperties = { fontSize: 10, padding: "1px 6px", border: "none", background: "none", color: "#b91c1c", cursor: "pointer" };
255+
const smallGhost: React.CSSProperties = { fontSize: 10, padding: "1px 6px", border: "none", background: "none", color: "#6b7280", cursor: "pointer" };

‎app/src/components/InterviewDialog.test.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,15 @@ describe("InterviewDialog 访谈模式(四步 + 宣言)", () => {
8686
expect(screen.getByTestId("draft-title-0")).toBeTruthy();
8787
});
8888

89-
it("宣言页:草案预填可删改,创建契约携带删改后草案", async () => {
89+
it("宣言页:草案预填可删改,创建契约携带删改后草案与时限选择", async () => {
9090
const onCreated = vi.fn();
9191
render(<InterviewDialog mode="interview" groups={[{ id: 3, name: "Python 组" }]} onClose={vi.fn()} onCreated={onCreated} />);
9292
fireEvent.change(screen.getByTestId("interview-name"), { target: { value: "学会 Python" } });
9393
fireEvent.change(screen.getByTestId("scenario-input"), { target: { value: "独立项目" } });
9494
fireEvent.click(screen.getByTestId("next-step"));
9595
fireEvent.click(screen.getByTestId("skip-step")); // 现状跳过
9696
fireEvent.click(screen.getByTestId("tier-chip-solo_project"));
97+
fireEvent.click(screen.getByTestId("horizon-chip-6m"));
9798
fireEvent.click(screen.getByTestId("next-step"));
9899
fireEvent.click(screen.getByTestId("skip-step")); // 素材跳过
99100
await waitFor(() => expect(screen.getByTestId("draft-title-0")).toBeTruthy());
@@ -104,7 +105,10 @@ describe("InterviewDialog 访谈模式(四步 + 宣言)", () => {
104105
await waitFor(() => {
105106
const call = invokeMock.mock.calls.find((c) => c[0] === "create_goal");
106107
expect(call).toBeTruthy();
107-
const milestones = (call![1] as { input: { milestones: { title: string; dueWeeks: number }[] } }).input.milestones;
108+
const input = (call![1] as { input: { horizon: string; milestones: { title: string; dueWeeks: number }[] } }).input;
109+
// 审查修复(2026-09-02):时效取第 3 问 chips(a.horizon)而非面板默认值
110+
expect(input.horizon).toBe("6m");
111+
const milestones = input.milestones;
108112
expect(milestones).toHaveLength(2);
109113
expect(milestones[0].title).toBe("应用:自己写一个小脚本");
110114
expect(milestones[0].dueWeeks).toBe(8);

‎app/src/components/InterviewDialog.tsx‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ interface Props {
2121
mode: "interview" | "quick";
2222
groups: { id: number; name: string }[];
2323
onClose: () => void;
24-
onCreated: (goal: Goal) => void;
25-
/** 编辑态(目标详情→重新访谈):走 update_goal_interview(配方重推,称号不变) */
24+
/** 创建成功(或编辑保存成功)回调——不携带目标对象(编辑态无新目标,调用方自行刷新) */
25+
onCreated: () => void;
26+
/** 编辑态(目标详情→重新访谈):走 update_goal_interview(配方重推,名称随对话框生效) */
2627
goalId?: number;
28+
/** 预填名称(空态热词入口) */
29+
initialName?: string;
2730
}
2831

2932
const HORIZON_OPTIONS = [
@@ -40,8 +43,8 @@ const TIER_LABELS: Record<string, string> = {
4043
default: "说不清:全部里程碑 + ≥1 组结算 + 近 90 天复习活跃",
4144
};
4245

43-
export default function InterviewDialog({ mode, groups, onClose, onCreated, goalId }: Props) {
44-
const [name, setName] = useState("");
46+
export default function InterviewDialog({ mode, groups, onClose, onCreated, goalId, initialName }: Props) {
47+
const [name, setName] = useState(initialName ?? "");
4548
const [horizon, setHorizon] = useState("3m");
4649
const [a, setA] = useState<InterviewAnswers>(EMPTY_ANSWERS);
4750
const [drafts, setDrafts] = useState<MilestoneDraft[]>([]);
@@ -64,7 +67,9 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
6467
}, [step, mode, a.level, a.weeklyCommitment, suggestKey]);
6568

6669
const tierLabel = TIER_LABELS[a.tier] ?? "";
67-
const declaration = assembleDeclarationPreview(name, horizon, a, tierLabel);
70+
// 时效口径:访谈模式取第 3 问 chips(a.horizon),快速模式取顶部下拉(horizon)
71+
const effectiveHorizon = a.horizon || horizon;
72+
const declaration = assembleDeclarationPreview(name, effectiveHorizon, a, tierLabel);
6873

6974
const create = async () => {
7075
setSaving(true);
@@ -73,17 +78,17 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
7378
const input = mode === "quick"
7479
? toQuickInput(name, horizon)
7580
: toCreateInput(
76-
name, horizon, a,
81+
name, effectiveHorizon, a,
7782
drafts.filter((d) => d.title.trim()).map((d) => ({ title: d.title.trim(), dueWeeks: d.dueWeeks })),
7883
);
7984
if (goalId != null) {
80-
// 编辑态:配方重推(判据/意图整体重写;名称与绑组不变)
85+
// 编辑态:配方重推(判据/意图整体重写;名称随对话框生效,绑组不变)
8186
await invoke<boolean>("update_goal_interview", { id: goalId, input });
82-
onCreated({ id: goalId, name, status: "active", domainTag: null, horizonEnd: null, successCriteriaJson: "", intentJson: "", createdAt: 0, completedAt: null, updatedAt: 0 });
87+
onCreated();
8388
return;
8489
}
85-
const goal = await invoke<Goal>("create_goal", { input });
86-
onCreated(goal);
90+
await invoke<Goal>("create_goal", { input });
91+
onCreated();
8792
} catch (e) {
8893
setErr(`创建失败: ${e}`);
8994
} finally {

‎app/src/components/InterviewSteps.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export function StepCriteria({ a, setA }: StepProps) {
9999
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 6 }}>时间怎么算?</div>
100100
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
101101
{HORIZON_OPTIONS.map((o) => (
102-
<Chip key={o.id} active={a.horizon === o.id} onClick={() => setA({ horizon: o.id })}>{o.label}</Chip>
102+
<Chip key={o.id} testid={`horizon-chip-${o.id}`} active={a.horizon === o.id}
103+
onClick={() => setA({ horizon: o.id })}>{o.label}</Chip>
103104
))}
104105
</div>
105106
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 4 }}>明确不学什么?(防目标沼泽化)</div>
@@ -177,7 +178,7 @@ export function StepDeclaration({ name, declaration, drafts, onDraftChange }: De
177178
</div>
178179
{drafts.map((d, i) => (
179180
<div key={i} style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 6 }}>
180-
<span style={{ fontSize: 12, color: "#9ca3af", width: 54 }}>第{d.dueWeeks}周</span>
181+
<span style={{ fontSize: 12, color: "#9ca3af", width: 54 }}>{d.dueWeeks === 0 ? "无期限" : `第${d.dueWeeks}周`}</span>
181182
<input
182183
data-testid={`draft-title-${i}`}
183184
value={d.title}

‎app/src/pages/GoalsPage.tsx‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,18 @@
88
*/
99
import { useCallback, useEffect, useState } from "react";
1010
import { invoke } from "@tauri-apps/api/core";
11-
import type { Goal, GoalCardView } from "../types/goals";
11+
import type { GoalCardView } from "../types/goals";
1212
import GoalCard from "../components/GoalCard";
1313
import GoalDetail from "../components/GoalDetail";
1414
import InterviewDialog from "../components/InterviewDialog";
1515

16-
type DialogMode = "interview" | "quick" | null;
17-
1816
const HOT_DOMAINS = ["学 Python", "练听力", "画水彩"];
1917

2018
export default function GoalsPage() {
2119
const [cards, setCards] = useState<GoalCardView[]>([]);
2220
const [selectedId, setSelectedId] = useState<number | null>(null);
2321
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
24-
const [dialog, setDialog] = useState<DialogMode>(null);
22+
const [dialog, setDialog] = useState<{ mode: "interview" | "quick"; name?: string } | null>(null);
2523
const [err, setErr] = useState("");
2624
const [loaded, setLoaded] = useState(false);
2725

@@ -44,7 +42,7 @@ export default function GoalsPage() {
4442
.catch((e) => setErr(`组列表加载失败: ${e}`));
4543
}, [load]);
4644

47-
const onCreated = (_goal: Goal) => {
45+
const onCreated = () => {
4846
setDialog(null);
4947
void load();
5048
};
@@ -54,8 +52,8 @@ export default function GoalsPage() {
5452
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid #e5e7eb", flexShrink: 0 }}>
5553
<span style={{ fontWeight: 700, fontSize: 14 }}>🎯 目标</span>
5654
<span style={{ fontSize: 11, color: "#9ca3af" }}>意图层——把素材串成可毕业的目标</span>
57-
<button data-testid="open-interview" onClick={() => setDialog("interview")} style={primaryBtn}>+ 新建目标(访谈)</button>
58-
<button data-testid="open-quick" onClick={() => setDialog("quick")} style={ghostBtn}>只想简单记一下</button>
55+
<button data-testid="open-interview" onClick={() => setDialog({ mode: "interview" })} style={primaryBtn}>+ 新建目标(访谈)</button>
56+
<button data-testid="open-quick" onClick={() => setDialog({ mode: "quick" })} style={ghostBtn}>只想简单记一下</button>
5957
</div>
6058

6159
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
@@ -69,7 +67,7 @@ export default function GoalsPage() {
6967
{HOT_DOMAINS.map((h) => (
7068
<button
7169
key={h}
72-
onClick={() => { setDialog("interview"); }}
70+
onClick={() => setDialog({ mode: "interview", name: h })}
7371
style={{ ...ghostBtn, margin: "0 4px" }}
7472
>
7573
{h}
@@ -97,8 +95,9 @@ export default function GoalsPage() {
9795

9896
{dialog && (
9997
<InterviewDialog
100-
mode={dialog}
98+
mode={dialog.mode}
10199
groups={groups}
100+
initialName={dialog.name}
102101
onClose={() => setDialog(null)}
103102
onCreated={onCreated}
104103
/>

‎app/src/utils/goalInterview.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export function horizonLabel(horizon: string): string {
113113
case "3m": return "3 个月";
114114
case "6m": return "半年";
115115
case "2w": return "先试两周";
116-
default: return horizon ? "12 周" : "12 周";
116+
case "none": return "无期限";
117+
default: return "12 周";
117118
}
118119
}
119120

@@ -126,5 +127,10 @@ export function assembleDeclarationPreview(
126127
): string {
127128
const standard = a.criteriaStatement.trim() || tierLabel || "(未判定标准)";
128129
const scene = a.scenario.trim() ? `(场景:${a.scenario.trim()})` : "";
129-
return `用${horizonLabel(horizon)}学会${name.trim()}${scene},达成标准:${standard};边界:${a.nonScope.trim() || "暂未明确"}`;
130+
const boundary = a.nonScope.trim() || "暂未明确";
131+
if (a.horizon === "none" || horizon === "none") {
132+
// 无期限 = 长期目标语义(与后端 assemble_declaration 分支一致)
133+
return `长期目标(无期限):学会${name.trim()}${scene},达成标准:${standard};边界:${boundary}`;
134+
}
135+
return `用${horizonLabel(a.horizon || horizon)}学会${name.trim()}${scene},达成标准:${standard};边界:${boundary}`;
130136
}

0 commit comments

Comments
 (0)