Skip to content

Commit aa341c4

Browse files
committed
feat(groups): 周契约卡与 feed 碎片列表 UI(REQ-200/201)
1 parent 9963be6 commit aa341c4

5 files changed

Lines changed: 372 additions & 1 deletion

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* FeedFragmentList — feed 组碎片列表(v0.11.4 REQ-201;消费闭环 UI)。
3+
*
4+
* @ai-context: feed 消费闭环的呈现面——碎片不再是"捕获后看不见"的半截体验:
5+
* 文本预览 + 图片缩略(resolve_fragment_image 后端校验路径,
6+
* convertFileSrc 转 asset 协议)+ 删除/移出组操作 + 空态引导。
7+
* @ai-context: 删除为真删(用户主动操作),绑定卡自动解绑保留(卡是独立资产);
8+
* 开关关闭时入口不可见(父组件控制),命令层另有二次校验。
9+
* @ai-context: 零新界面纪律——本列表嵌入组侧栏展开区,不新建页面。
10+
*/
11+
import { useCallback, useEffect, useState } from "react";
12+
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
13+
import type { Fragment } from "../types";
14+
15+
interface Props {
16+
groupId: number;
17+
/** 删除/移出后回调(父组件刷新组列表等) */
18+
onChanged: () => void;
19+
}
20+
21+
/** 碎片图片缩略(后端 resolve 校验 → asset 协议;无图/失败降级文本) */
22+
function FragmentThumb({ fragment }: { fragment: Fragment }) {
23+
const [url, setUrl] = useState<string | null>(null);
24+
useEffect(() => {
25+
let disposed = false;
26+
if (!fragment.imagePath) {
27+
setUrl(null);
28+
return;
29+
}
30+
invoke<string | null>("resolve_fragment_image", { fragmentId: fragment.id })
31+
.then((abs) => {
32+
if (!disposed && abs) setUrl(convertFileSrc(abs));
33+
})
34+
.catch(() => undefined);
35+
return () => {
36+
disposed = true;
37+
};
38+
}, [fragment]);
39+
if (!url) return null;
40+
return (
41+
<img
42+
src={url}
43+
alt="碎片截图"
44+
loading="lazy"
45+
style={{ maxWidth: "100%", maxHeight: 72, borderRadius: 4, border: "1px solid #e5e7eb", marginTop: 4 }}
46+
/>
47+
);
48+
}
49+
50+
export default function FeedFragmentList({ groupId, onChanged }: Props) {
51+
const [fragments, setFragments] = useState<Fragment[]>([]);
52+
const [loaded, setLoaded] = useState(false);
53+
const [err, setErr] = useState("");
54+
55+
const load = useCallback(async () => {
56+
try {
57+
const list = await invoke<Fragment[]>("list_group_fragments", { groupId });
58+
setFragments(list);
59+
setErr("");
60+
} catch (e) {
61+
setErr(`碎片加载失败: ${e}`);
62+
} finally {
63+
setLoaded(true);
64+
}
65+
}, [groupId]);
66+
67+
useEffect(() => {
68+
setLoaded(false);
69+
void load();
70+
}, [load]);
71+
72+
const runDelete = async (f: Fragment) => {
73+
if (!window.confirm(`删除这条碎片?\n「${f.text.slice(0, 30)}…」\n(绑定的闪卡会保留)`)) return;
74+
try {
75+
await invoke<boolean>("delete_fragment", { fragmentId: f.id });
76+
await load();
77+
onChanged();
78+
} catch (e) {
79+
setErr(`删除失败: ${e}`);
80+
}
81+
};
82+
83+
const runUngroup = async (f: Fragment) => {
84+
try {
85+
await invoke<boolean>("update_fragment_group", { fragmentId: f.id, groupId: null });
86+
await load();
87+
onChanged();
88+
} catch (e) {
89+
setErr(`移出组失败: ${e}`);
90+
}
91+
};
92+
93+
return (
94+
<div style={{ marginTop: 8, padding: 8, background: "#fff", borderRadius: 6, border: "1px solid #e5e7eb" }}>
95+
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
96+
<span style={{ fontWeight: 600, color: "#374151", fontSize: 12 }}>🧩 碎片({fragments.length})</span>
97+
<span style={{ fontSize: 10, color: "#9ca3af", marginLeft: "auto" }}>原料层 · 不是笔记</span>
98+
</div>
99+
100+
{loaded && fragments.length === 0 && (
101+
<p style={{ fontSize: 11, color: "#9ca3af", margin: "4px 0" }}>
102+
还没有碎片——用上方「碎片捕获」框记几条试试?
103+
</p>
104+
)}
105+
106+
{fragments.map((f) => (
107+
<div key={f.id} style={{ padding: "6px 0", borderTop: "1px solid #f3f4f6" }}>
108+
<div style={{ fontSize: 11, color: "#374151", lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
109+
{f.text.slice(0, 120)}
110+
{f.text.length > 120 && "…"}
111+
</div>
112+
<FragmentThumb fragment={f} />
113+
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
114+
<button
115+
onClick={() => void runUngroup(f)}
116+
style={{ fontSize: 10, cursor: "pointer", padding: "1px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}
117+
title="移出本组(碎片保留)"
118+
>
119+
移出组
120+
</button>
121+
<button
122+
onClick={() => void runDelete(f)}
123+
style={{ fontSize: 10, cursor: "pointer", padding: "1px 8px", borderRadius: 4, border: "1px solid #fecaca", background: "#fff", color: "#dc2626" }}
124+
title="删除碎片(绑定卡保留)"
125+
>
126+
删除
127+
</button>
128+
<span style={{ fontSize: 9, color: "#d1d5db", marginLeft: "auto", alignSelf: "center" }}>
129+
{new Date(f.createdAt * 1000).toLocaleDateString()}
130+
</span>
131+
</div>
132+
</div>
133+
))}
134+
135+
{err && <p style={{ fontSize: 11, color: "#dc2626", marginTop: 4 }}>{err}</p>}
136+
</div>
137+
);
138+
}

‎app/src/components/NoteGroupPanel.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import { useCallback, useEffect, useState } from "react";
1111
import { invoke } from "@tauri-apps/api/core";
1212
import type { GroupRouteReason, NoteGroup } from "../types";
13+
import FeedFragmentList from "./FeedFragmentList";
14+
import WeekContractCard from "./WeekContractCard";
1315

1416
/** 领域标签选项(与 Rust DomainKind 15 类同口径;改判下拉用) */
1517
const DOMAIN_OPTIONS: [string, string][] = [
@@ -387,6 +389,12 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
387389
</div>
388390
</div>
389391
)}
392+
{/* v0.11.4(REQ-200):周契约卡——弹性承诺呈现层(所有组展开区) */}
393+
<WeekContractCard groupId={g.id} />
394+
{/* v0.11.4(REQ-201):feed 组碎片列表——消费闭环(仅 feed 地形) */}
395+
{g.terrain === "feed" && (
396+
<FeedFragmentList groupId={g.id} onChanged={() => void load()} />
397+
)}
390398
</div>
391399
)}
392400
</div>

‎app/src/components/ReviewSessionOverlay.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ export default function ReviewSessionOverlay({ groupId, groupName, onClose }: Pr
109109

110110
{current && !finished && (
111111
<div>
112+
{/* 卡类型徽标(REQ-199 内容分型 + REQ-201 碎片卡——身份诚实可见) */}
113+
<div style={{ display: "flex", gap: 4, marginBottom: 6 }}>
114+
{current.fragmentId != null && (
115+
<span style={{ fontSize: 10, color: "#7c3aed", background: "#faf5ff", borderRadius: 8, padding: "0 6px" }}>
116+
🧩 碎片卡
117+
</span>
118+
)}
119+
{current.kind === "action" && (
120+
<span style={{ fontSize: 10, color: "#b45309", background: "#fffbeb", borderRadius: 8, padding: "0 6px" }}>
121+
⚡ 动作卡
122+
</span>
123+
)}
124+
</div>
112125
{/* front(线索——先回忆再看) */}
113126
<div style={{
114127
minHeight: 90, padding: 16, background: "#f9fafb", borderRadius: 8,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* WeekContractCard — 周契约卡(v0.11.4 REQ-200;弹性承诺呈现层)。
3+
*
4+
* @ai-context: P31+N10 的 UI 落点——契约是用户自设本周目标,不是打卡 KPI:
5+
* 无 streak、无惩罚、欠账不追(后端 review_logs 无 streak 字段)。
6+
* 本卡只呈现"承诺 vs 实际"两个数字 + 最小可行日徽标(N9/N11
7+
* 低谷生存的最轻形态:本周完成 ≥3 卡即成立,一天崩坏不否定整周)。
8+
* @ai-context: 断签不清零视觉——进度按本周(周一零点起)独立计算,下周归零
9+
* 重来,无连续记录展示;周界(weekStart UTC 秒)转本地日期展示。
10+
*/
11+
import { useCallback, useEffect, useState } from "react";
12+
import { invoke } from "@tauri-apps/api/core";
13+
import type { WeekContractStatus } from "../types";
14+
15+
interface Props {
16+
groupId: number;
17+
}
18+
19+
/** UTC 秒 → 本地日期短格式(周界展示用;epoch 前防御性回退空串) */
20+
function formatWeekStart(utcSecs: number): string {
21+
if (!utcSecs) return "";
22+
const d = new Date(utcSecs * 1000);
23+
if (Number.isNaN(d.getTime())) return "";
24+
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
25+
}
26+
27+
/** 简单进度条(宽度百分比 0..100 有界) */
28+
function bar(ratio: number): React.CSSProperties {
29+
const pct = Math.max(0, Math.min(100, Math.round(ratio * 100)));
30+
return {
31+
width: `${pct}%`,
32+
height: 6,
33+
background: pct >= 100 ? "#059669" : "#0f766e",
34+
borderRadius: 3,
35+
transition: "width 0.3s",
36+
};
37+
}
38+
39+
export default function WeekContractCard({ groupId }: Props) {
40+
const [status, setStatus] = useState<WeekContractStatus | null>(null);
41+
const [editing, setEditing] = useState(false);
42+
const [days, setDays] = useState(3);
43+
const [cards, setCards] = useState(15);
44+
const [saving, setSaving] = useState(false);
45+
const [err, setErr] = useState("");
46+
47+
const load = useCallback(async () => {
48+
try {
49+
const s = await invoke<WeekContractStatus>("week_contract_status", { groupId });
50+
setStatus(s);
51+
if (s.contract) {
52+
setDays(s.contract.targetDays);
53+
setCards(s.contract.targetCards);
54+
}
55+
setErr("");
56+
} catch (e) {
57+
setErr(`周契约加载失败: ${e}`);
58+
}
59+
}, [groupId]);
60+
61+
useEffect(() => {
62+
void load();
63+
}, [load]);
64+
65+
const save = async () => {
66+
setSaving(true);
67+
try {
68+
await invoke<unknown>("upsert_week_contract", { groupId, targetDays: days, targetCards: cards });
69+
setEditing(false);
70+
await load();
71+
} catch (e) {
72+
setErr(`契约保存失败: ${e}`);
73+
} finally {
74+
setSaving(false);
75+
}
76+
};
77+
78+
const c = status?.contract ?? null;
79+
const dayRatio = status && c ? status.actualDays / c.targetDays : 0;
80+
const cardRatio = status && c ? status.actualCards / c.targetCards : 0;
81+
82+
return (
83+
<div style={{ marginTop: 8, padding: 8, background: "#fff", borderRadius: 6, border: "1px solid #e5e7eb" }}>
84+
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
85+
<span style={{ fontWeight: 600, color: "#374151", fontSize: 12 }}>📅 周契约</span>
86+
{status?.minimalDayMet && (
87+
<span style={{ fontSize: 10, color: "#047857", background: "#ecfdf5", borderRadius: 8, padding: "0 5px" }}>
88+
✓ 本周成立
89+
</span>
90+
)}
91+
{status && (
92+
<span style={{ fontSize: 10, color: "#9ca3af", marginLeft: "auto" }}>
93+
本周自 {formatWeekStart(status.weekStart)} 起
94+
</span>
95+
)}
96+
</div>
97+
98+
{/* 未立约:设定表单 */}
99+
{!c && !editing && (
100+
<div>
101+
<p style={{ fontSize: 11, color: "#9ca3af", margin: "2px 0 6px" }}>
102+
给自己一个本周目标——断签不清零,欠账不追。
103+
</p>
104+
<button
105+
onClick={() => setEditing(true)}
106+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 10px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}
107+
>
108+
✍ 设定本周目标
109+
</button>
110+
</div>
111+
)}
112+
113+
{/* 设定/修改表单 */}
114+
{editing && (
115+
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginTop: 2 }}>
116+
<select
117+
value={days}
118+
onChange={(e) => setDays(Number(e.target.value))}
119+
style={{ fontSize: 11, padding: "2px 4px", border: "1px solid #e5e7eb", borderRadius: 4 }}
120+
title="本周承诺复习天数"
121+
>
122+
{[1, 2, 3, 4, 5, 6, 7].map((d) => (
123+
<option key={d} value={d}>{d} 天</option>
124+
))}
125+
</select>
126+
<input
127+
type="number"
128+
min={1}
129+
max={200}
130+
value={cards}
131+
onChange={(e) => setCards(Math.max(1, Number(e.target.value) || 1))}
132+
style={{ width: 64, fontSize: 11, padding: "2px 4px", border: "1px solid #e5e7eb", borderRadius: 4 }}
133+
title="本周承诺复习卡数"
134+
/>
135+
<span style={{ fontSize: 11, color: "#6b7280" }}>卡/周</span>
136+
<button
137+
onClick={() => void save()}
138+
disabled={saving}
139+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 10px", borderRadius: 4, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e" }}
140+
>
141+
{saving ? "保存中…" : c ? "✓ 改目标" : "✓ 立约"}
142+
</button>
143+
<button
144+
onClick={() => { setEditing(false); if (c) { setDays(c.targetDays); setCards(c.targetCards); } }}
145+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}
146+
>
147+
取消
148+
</button>
149+
</div>
150+
)}
151+
152+
{/* 已立约:承诺 vs 实际(断签不清零——进度按周独立) */}
153+
{status && c && !editing && (
154+
<div>
155+
<div style={{ margin: "2px 0" }}>
156+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#6b7280" }}>
157+
<span>天数 {status.actualDays}/{c.targetDays}</span>
158+
<span>卡数 {status.actualCards}/{c.targetCards}</span>
159+
</div>
160+
<div style={{ background: "#f3f4f6", borderRadius: 3, marginTop: 2 }}>
161+
<div style={bar(dayRatio)} />
162+
</div>
163+
<div style={{ background: "#f3f4f6", borderRadius: 3, marginTop: 2 }}>
164+
<div style={bar(cardRatio)} />
165+
</div>
166+
</div>
167+
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 4 }}>
168+
<span style={{ fontSize: 10, color: "#9ca3af" }}>弹性承诺:无连续打卡 · 断签不清零</span>
169+
<button
170+
onClick={() => setEditing(true)}
171+
style={{ marginLeft: "auto", fontSize: 10, cursor: "pointer", padding: "1px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}
172+
>
173+
改目标
174+
</button>
175+
</div>
176+
</div>
177+
)}
178+
179+
{err && <p style={{ fontSize: 11, color: "#dc2626", marginTop: 4 }}>{err}</p>}
180+
</div>
181+
);
182+
}

0 commit comments

Comments
 (0)