Skip to content

Commit e489137

Browse files
committed
feat(goal): v0.18.2 前端——规划确认流与双入口(✨访谈/💡详情)+ 最弱概念块(REQ-251~253)
- GoalPlanApprovalDialog:草案逐项勾选/标题编辑/体系 create-vs-link 单选/ 周契约可改/清洗登记与诚实提示展示/「改用规则草案」一键回退——建议制核心 UI - InterviewDialog 第 5 步「✨ 用 AI 规划」:先建基线目标→AI 规划→确认替换 里程碑草案(replaceMilestones=true 事务语义) - GoalDetail「💡 AI 规划」:详情重规划(增量追加)+ 最弱概念块(90 天未动, 规则信号 M3 真实化);失败均降级规则(不阻断) - 类型:GoalPlanProposal/PlanValidation/ConceptWeakness + AiSettings 目标字段 - tsc 零错误;vitest 509 全绿(既有套件回归)
1 parent 6d6039e commit e489137

5 files changed

Lines changed: 371 additions & 5 deletions

File tree

‎app/src/components/GoalDetail.tsx‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
*/
99
import { useCallback, useEffect, useState } from "react";
1010
import { invoke } from "@tauri-apps/api/core";
11-
import type { GoalDetailView, GoalProgressView, GroupWeakness } from "../types/goals";
11+
import type { ConceptWeaknessView, GoalDetailView, GoalPlanView, GoalProgressView, GroupWeakness } from "../types/goals";
1212
import { GOAL_STATUS_LABELS } from "../types/goals";
1313
import InterviewDialog from "./InterviewDialog";
1414
import GraduateDialog from "./GraduateDialog";
1515
import RetroTimeline from "./RetroTimeline";
16+
import GoalPlanApprovalDialog from "./GoalPlanApprovalDialog";
1617

1718
interface Props {
1819
goalId: number;
@@ -37,6 +38,10 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
3738
const [graduateOpen, setGraduateOpen] = useState(false);
3839
const [abandonMode, setAbandonMode] = useState(false);
3940
const [abandonReason, setAbandonReason] = useState("");
41+
// v0.18.2:AI 规划(详情页重规划——增量追加)/ 最弱概念(M3 真实化规则信号)
42+
const [aiPlan, setAiPlan] = useState<GoalPlanView | null>(null);
43+
const [aiPlanning, setAiPlanning] = useState(false);
44+
const [weakConcepts, setWeakConcepts] = useState<ConceptWeaknessView[]>([]);
4045

4146
const refresh = useCallback(async () => {
4247
try {
@@ -55,7 +60,24 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
5560
invoke<{ id: number; name: string }[]>("list_note_groups", { terrain: "container" })
5661
.then((gs) => setGroups(gs))
5762
.catch((e) => setErr(`组列表加载失败: ${e}`));
58-
}, []);
63+
// v0.18.2:最弱概念(规则信号现算——M3 真实化)
64+
invoke<ConceptWeaknessView[]>("goal_concept_weakness", { goalId })
65+
.then((cs) => setWeakConcepts(cs.filter((c) => c.weak).slice(0, 5)))
66+
.catch(() => { /* 无体系/概念时为空——次要块降级 */ });
67+
}, [goalId]);
68+
69+
const runAiPlan = async () => {
70+
setAiPlanning(true);
71+
setErr("");
72+
try {
73+
const view = await invoke<GoalPlanView>("ai_goal_plan", { goalId, tier: null, authorized: true });
74+
setAiPlan(view);
75+
} catch (e) {
76+
setErr(`AI 规划失败: ${e}`);
77+
} finally {
78+
setAiPlanning(false);
79+
}
80+
};
5981

6082
if (err && !detail) return <div style={{ padding: 24, fontSize: 13, color: "#dc2626" }}>{err}</div>;
6183
if (!detail) return <div style={{ padding: 24, fontSize: 13, color: "#9ca3af" }}>加载中…</div>;
@@ -233,8 +255,7 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
233255
)}
234256

235257
{/* 弱项块(M1:FSRS 低稳定性卡占比 Top 组) */}
236-
<SectionTitle>最弱一块(FSRS 低稳定性卡占比)</SectionTitle>
237-
{p && p.weakGroups.length > 0 ? (
258+
<SectionTitle>最弱一块(FSRS 低稳定性卡占比)</SectionTitle> {p && p.weakGroups.length > 0 ? (
238259
p.weakGroups.map((w: GroupWeakness) => (
239260
<div key={w.groupId} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
240261
<span style={{ fontSize: 11, width: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{w.groupName}</span>
@@ -248,12 +269,33 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
248269
<p style={{ fontSize: 11, color: "#9ca3af", margin: "0 0 10px" }}>暂无弱项信号(绑定组无卡)</p>
249270
)}
250271

272+
{/* v0.18.2:最弱概念(90 天无引用/未应用——规则信号,M3 真实化) */}
273+
{weakConcepts.length > 0 && (
274+
<>
275+
<SectionTitle>最弱概念(90 天未动)</SectionTitle>
276+
{weakConcepts.map((c) => (
277+
<div key={c.conceptId} style={{ fontSize: 11, color: "#b45309", padding: "2px 0" }}>
278+
○ {c.name}——{c.reason}
279+
</div>
280+
))}
281+
</>
282+
)}
283+
251284
{/* 动作区 */}
252285
<div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
253286
<button onClick={() => void changeStatus(goal.status === "paused" ? "active" : "paused")} style={miniPrimary}>
254287
{goal.status === "paused" ? "▶ 恢复" : "⏸ 暂停"}
255288
</button>
256289
<button onClick={() => setEditing(true)} style={miniPrimary}>✎ 重新访谈</button>
290+
<button
291+
data-testid="ai-plan-detail"
292+
onClick={() => void runAiPlan()}
293+
disabled={aiPlanning}
294+
style={{ ...miniPrimary, border: "1px solid #7c3aed", color: "#7c3aed" }}
295+
title="AI 规划建议(默认关;失败/超限不影响规则能力)"
296+
>
297+
{aiPlanning ? "✨ 规划中…" : "💡 AI 规划"}
298+
</button>
257299
{goal.status === "active" || goal.status === "paused" ? (
258300
<>
259301
<button
@@ -308,6 +350,20 @@ export default function GoalDetail({ goalId, onChanged, onDeleted }: Props) {
308350
onGraduated={() => { setGraduateOpen(false); onChanged(); void refresh(); }}
309351
/>
310352
)}
353+
{aiPlan && (
354+
<GoalPlanApprovalDialog
355+
view={aiPlan}
356+
onClose={() => setAiPlan(null)}
357+
onUseRules={() => { setAiPlan(null); setErr("已改用规则草案——AI 建议未采纳"); }}
358+
onConfirm={async (req) => {
359+
await invoke("goal_apply_plan", {
360+
goalId,
361+
request: { ...req, replaceMilestones: false },
362+
});
363+
onChanged(); await refresh();
364+
}}
365+
/>
366+
)}
311367
</div>
312368
);
313369
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* GoalPlanApprovalDialog — 规划草案确认流(双入口共用;建议制核心 UI)。
3+
*
4+
* @ai-context: ADR-028 §2——AI 产出全是草案:里程碑(勾选/删改)/组绑定/
5+
* 体系(create vs link 单选)/周契约(可改)/摘要展示;确认后
6+
* goal_apply_plan 逐项落库(systems 独立事务——部分失败不污染)。
7+
* @ai-context: 「改用规则草案」一键回退 M1 suggest_milestones(降级基线);
8+
* 清理登记(丢弃项)诚实展示,绝不静默。
9+
*/
10+
import { useMemo, useState } from "react";
11+
import type { GoalPlanView } from "../types/goals";
12+
13+
interface Props {
14+
view: GoalPlanView;
15+
/** 确认回调(携带用户勾选/编辑后的请求——由调用方决定落库方式) */
16+
onConfirm: (req: GoalApplyReq) => Promise<void>;
17+
onClose: () => void;
18+
/** 切换规则草案(AI 建议不采纳——回到 M1 规则基线) */
19+
onUseRules: () => void;
20+
}
21+
22+
/** 确认流请求(前端逐项勾选/编辑后的确定版本——Rust GoalApplyRequest 契约)。 */
23+
export interface GoalApplyReq {
24+
milestones: { title: string; dueWeeks: number; criteriaType: string; refGroupId: number | null; note: string }[];
25+
groupIds: number[];
26+
weeklyContract: { targetDays: number; targetCards: number } | null;
27+
systems: { action: string; systemId: number | null; name: string | null; coreQuestion: string | null; domainEntries: string[]; concepts: { name: string; essence: string; boundary: string; relation: string }[]; reason: string }[];
28+
}
29+
30+
export default function GoalPlanApprovalDialog({ view, onConfirm, onClose, onUseRules }: Props) {
31+
const [selectedMilestones, setSelectedMilestones] = useState<number[]>(
32+
view.proposal.milestones.map((_, i) => i),
33+
);
34+
const [selectedGroups, setSelectedGroups] = useState<number[]>(
35+
view.proposal.groups.map((_, i) => i),
36+
);
37+
const [selectedSystems, setSelectedSystems] = useState<number[]>(
38+
view.proposal.systems.map((_, i) => i),
39+
);
40+
const [days, setDays] = useState(view.proposal.weeklyContract?.targetDays ?? 3);
41+
const [cards, setCards] = useState(view.proposal.weeklyContract?.targetCards ?? 20);
42+
const [editingTitles, setEditingTitles] = useState<Record<number, string>>({});
43+
const [saving, setSaving] = useState(false);
44+
const [err, setErr] = useState("");
45+
46+
const milestoneTitles = useMemo(() => {
47+
const m: Record<number, string> = {};
48+
view.proposal.milestones.forEach((ms, i) => { m[i] = ms.title; });
49+
return { ...editingTitles, ...m };
50+
}, [view, editingTitles]);
51+
52+
const confirm = async () => {
53+
setSaving(true);
54+
setErr("");
55+
try {
56+
await onConfirm({
57+
milestones: selectedMilestones.map((i) => ({
58+
title: milestoneTitles[i] ?? view.proposal.milestones[i].title,
59+
dueWeeks: view.proposal.milestones[i].dueWeeks,
60+
criteriaType: view.proposal.milestones[i].criteriaType,
61+
refGroupId: view.proposal.milestones[i].refGroupId,
62+
note: view.proposal.milestones[i].note,
63+
})),
64+
groupIds: selectedGroups.map((i) => view.proposal.groups[i].groupId),
65+
weeklyContract: view.proposal.weeklyContract ? { targetDays: days, targetCards: cards } : null,
66+
systems: selectedSystems.map((i) => view.proposal.systems[i]),
67+
});
68+
onClose();
69+
} catch (e) {
70+
setErr(`落库失败: ${e}`);
71+
} finally {
72+
setSaving(false);
73+
}
74+
};
75+
76+
return (
77+
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 60 }}>
78+
<div data-testid="plan-approval" style={{ width: 560, maxHeight: "86vh", overflow: "auto", background: "#fff", borderRadius: 10, padding: 18, boxSizing: "border-box" }}>
79+
<div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
80+
<span style={{ fontWeight: 700, fontSize: 15 }}>✨ AI 规划建议(草案——确认后落库)</span>
81+
<button onClick={onClose} style={{ marginLeft: "auto", border: "none", background: "none", fontSize: 14, cursor: "pointer", color: "#9ca3af" }}>✕</button>
82+
</div>
83+
{view.proposal.summary && (
84+
<p style={{ fontSize: 12, color: "#374151", background: "#fafaf9", padding: 8, borderRadius: 6, margin: "0 0 8px" }}>{view.proposal.summary}</p>
85+
)}
86+
{view.dropped.droppedMilestones.length + view.dropped.droppedGroups.length + view.dropped.droppedSystems.length > 0 && (
87+
<p style={{ fontSize: 11, color: "#b45309", background: "#fffbeb", padding: "6px 8px", borderRadius: 6, margin: "0 0 8px" }}>
88+
已剔除不合法建议 {view.dropped.droppedMilestones.length + view.dropped.droppedGroups.length + view.dropped.droppedSystems.length} 条(详见详情页记载)
89+
</p>
90+
)}
91+
{view.honestNote && <p style={{ fontSize: 11, color: "#b45309", margin: "0 0 8px" }}>{view.honestNote}</p>}
92+
93+
<SubTitle>里程碑({selectedMilestones.length} 条已选)</SubTitle>
94+
{view.proposal.milestones.length === 0 && <Empty>无里程碑建议——可改用规则草案或稍后在详情页添加</Empty>}
95+
{view.proposal.milestones.map((m, i) => (
96+
<div key={i} style={{ display: "flex", gap: 6, alignItems: "center", padding: "3px 0" }}>
97+
<input
98+
type="checkbox"
99+
checked={selectedMilestones.includes(i)}
100+
onChange={(e) => setSelectedMilestones((s) => e.target.checked ? [...s, i] : s.filter((x) => x !== i))}
101+
/>
102+
<span style={{ fontSize: 11, color: "#9ca3af", width: 46 }}>{m.dueWeeks}周</span>
103+
<input
104+
data-testid={`plan-milestone-${i}`}
105+
value={milestoneTitles[i]}
106+
onChange={(e) => setEditingTitles((t) => ({ ...t, [i]: e.target.value }))}
107+
style={{ flex: 1, fontSize: 12, padding: "2px 6px", border: "1px solid #e5e7eb", borderRadius: 4 }}
108+
/>
109+
{m.criteriaType === "group_settled" && <span style={{ fontSize: 10, color: "#0f766e" }}>随结算</span>}
110+
</div>
111+
))}
112+
113+
<SubTitle>绑定组({selectedGroups.length} 条已选)</SubTitle>
114+
{view.proposal.groups.length === 0 && <Empty>无组建议</Empty>}
115+
{view.proposal.groups.map((g, i) => (
116+
<label key={i} style={{ display: "flex", gap: 6, alignItems: "center", fontSize: 12, padding: "2px 0", cursor: "pointer" }}>
117+
<input
118+
type="checkbox"
119+
checked={selectedGroups.includes(i)}
120+
onChange={(e) => setSelectedGroups((s) => e.target.checked ? [...s, i] : s.filter((x) => x !== i))}
121+
/>
122+
组 #{g.groupId} {g.reason && <span style={{ color: "#9ca3af", fontSize: 11 }}>({g.reason})</span>}
123+
</label>
124+
))}
125+
126+
<SubTitle>体系({selectedSystems.length} 条已选)</SubTitle>
127+
{view.proposal.systems.length === 0 && <Empty>无体系建议——可稍后手动挂接/新建</Empty>}
128+
{view.proposal.systems.map((s, i) => (
129+
<label key={i} style={{ display: "flex", gap: 6, alignItems: "flex-start", fontSize: 12, padding: "2px 0", cursor: "pointer" }}>
130+
<input
131+
type="checkbox"
132+
checked={selectedSystems.includes(i)}
133+
onChange={(e) => setSelectedSystems((sel) => e.target.checked ? [...sel, i] : sel.filter((x) => x !== i))}
134+
/>
135+
<span>
136+
{s.action === "create" ? `新建体系「${s.name}」(核心问题:${s.coreQuestion};入口 ${s.domainEntries.length};初始概念 ${s.concepts.length})`
137+
: `挂接现有体系 #${s.systemId}`}
138+
</span>
139+
</label>
140+
))}
141+
142+
{view.proposal.weeklyContract && (
143+
<>
144+
<SubTitle>周契约建议(可改)</SubTitle>
145+
<div style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 12 }}>
146+
<span>每周</span>
147+
<input type="number" min={1} max={7} value={days} onChange={(e) => setDays(Math.max(1, Math.min(7, Number(e.target.value) || 1)))} style={numStyle} />
148+
<span>天 ·</span>
149+
<input type="number" min={1} max={200} value={cards} onChange={(e) => setCards(Math.max(1, Math.min(200, Number(e.target.value) || 1)))} style={numStyle} />
150+
<span>卡(弹性承诺:断签不清零)</span>
151+
</div>
152+
</>
153+
)}
154+
155+
{err && <p data-testid="plan-error" style={{ fontSize: 11, color: "#dc2626", margin: "6px 0 0" }}>{err}</p>}
156+
<div style={{ display: "flex", gap: 8, marginTop: 14, justifyContent: "flex-end" }}>
157+
<button onClick={onUseRules} style={ghostBtn} title="不采纳 AI 建议——回到 M1 规则草案(永远可用的降级基线)">改用规则草案</button>
158+
<button onClick={onClose} style={ghostBtn}>再想想</button>
159+
<button data-testid="plan-confirm" onClick={() => void confirm()} disabled={saving} style={primaryBtn}>
160+
{saving ? "落库中…" : "✓ 确认采用"}
161+
</button>
162+
</div>
163+
</div>
164+
</div>
165+
);
166+
}
167+
168+
function SubTitle({ children }: { children: React.ReactNode }) {
169+
return <div style={{ fontWeight: 600, fontSize: 12, color: "#374151", margin: "10px 0 4px" }}>{children}</div>;
170+
}
171+
function Empty({ children }: { children: React.ReactNode }) {
172+
return <div style={{ fontSize: 11, color: "#9ca3af" }}>{children}</div>;
173+
}
174+
175+
const numStyle: React.CSSProperties = { width: 56, fontSize: 12, padding: "2px 6px", border: "1px solid #e5e7eb", borderRadius: 4 };
176+
const primaryBtn: React.CSSProperties = { fontSize: 12, padding: "6px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #0f766e", background: "#0f766e", color: "#fff" };
177+
const ghostBtn: React.CSSProperties = { fontSize: 12, padding: "6px 12px", borderRadius: 6, cursor: "pointer", border: "1px solid #d1d5db", background: "#fff", color: "#4b5563" };

‎app/src/components/InterviewDialog.tsx‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
*/
1111
import { useEffect, useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
13-
import type { Goal, MilestoneDraft } from "../types/goals";
13+
import type { Goal, GoalPlanView, MilestoneDraft } from "../types/goals";
1414
import type { InterviewAnswers } from "../utils/goalInterview";
1515
import {
1616
EMPTY_ANSWERS, interviewMissing, assembleDeclarationPreview, toCreateInput, toQuickInput,
1717
} from "../utils/goalInterview";
1818
import { StepCriteria, StepDeclaration, StepFeasibility, StepLevelDriver, StepScenario } from "./InterviewSteps";
19+
import GoalPlanApprovalDialog from "./GoalPlanApprovalDialog";
1920

2021
interface Props {
2122
mode: "interview" | "quick";
@@ -52,6 +53,10 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
5253
const [err, setErr] = useState("");
5354
const [saving, setSaving] = useState(false);
5455
const [suggestKey, setSuggestKey] = useState("");
56+
// v0.18.2:AI 规划(先建目标(规则基线)→ 规划 → 确认替换草案)
57+
const [aiPlan, setAiPlan] = useState<GoalPlanView | null>(null);
58+
const [aiPlanning, setAiPlanning] = useState(false);
59+
const [plannedGoalId, setPlannedGoalId] = useState<number | null>(null);
5560

5661
const patch = (p: Partial<InterviewAnswers>) => setA((prev) => ({ ...prev, ...p }));
5762

@@ -104,6 +109,30 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
104109
setStep((s) => Math.min(4, s + 1));
105110
};
106111

112+
// v0.18.2:✨ AI 规划——先按当前答案建目标(规则基线),再 AI 规划 →
113+
// 确认对话框(替换里程碑草案——建议制语义:人类确认后落库)
114+
const runAiPlan = async () => {
115+
setAiPlanning(true);
116+
setErr("");
117+
try {
118+
let gid = plannedGoalId;
119+
if (gid == null) {
120+
const input = toCreateInput(name, effectiveHorizon, a, []);
121+
const goal = await invoke<Goal>("create_goal", { input });
122+
gid = goal.id;
123+
setPlannedGoalId(gid);
124+
}
125+
const view = await invoke<GoalPlanView>("ai_goal_plan", {
126+
goalId: gid, tier: null, authorized: true,
127+
});
128+
setAiPlan(view);
129+
} catch (e) {
130+
setErr(`AI 规划失败: ${e}`);
131+
} finally {
132+
setAiPlanning(false);
133+
}
134+
};
135+
107136
// 快速模式:两字段 + 创建(访谈模式走步骤向导)
108137
const quickMode = mode === "quick";
109138

@@ -184,6 +213,17 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
184213
{!quickMode && (step === 1 || step === 3) && (
185214
<button data-testid="skip-step" onClick={() => setStep((s) => s + 1)} style={ghostBtn}>跳过/以后想</button>
186215
)}
216+
{!quickMode && step === 4 && (
217+
<button
218+
data-testid="ai-plan-button"
219+
onClick={() => void runAiPlan()}
220+
disabled={aiPlanning}
221+
style={{ ...ghostBtn, border: "1px solid #7c3aed", color: "#7c3aed" }}
222+
title="AI 规划建议(默认关;未开启时失败仍按规则草案继续——本地可靠)"
223+
>
224+
{aiPlanning ? "✨ 规划中…" : "✨ 用 AI 规划"}
225+
</button>
226+
)}
187227
{!quickMode && step < 4 && (
188228
<button data-testid="next-step" onClick={next} style={primaryBtn}>下一步</button>
189229
)}
@@ -194,6 +234,20 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
194234
)}
195235
</div>
196236
</div>
237+
{/* v0.18.2:AI 规划确认流(建议制——确认后替换规则草案落库) */}
238+
{aiPlan && plannedGoalId != null && (
239+
<GoalPlanApprovalDialog
240+
view={aiPlan}
241+
onClose={() => setAiPlan(null)}
242+
onUseRules={() => { setAiPlan(null); setErr("已改用规则草案——AI 建议未采纳"); }}
243+
onConfirm={async (req) => {
244+
await invoke("goal_apply_plan", {
245+
goalId: plannedGoalId,
246+
request: { ...req, replaceMilestones: true },
247+
});
248+
}}
249+
/>
250+
)}
197251
</div>
198252
);
199253
}

0 commit comments

Comments
 (0)