|
| 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" }; |
0 commit comments