|
| 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