Skip to content

Commit 03e8393

Browse files
committed
feat(groups): 笔记页组侧栏与路由可见可改交互
1 parent 55ca4cf commit 03e8393

3 files changed

Lines changed: 272 additions & 1 deletion

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* NoteGroupPanel — 笔记组侧栏(v0.11.0 REQ-195~198)。
3+
*
4+
* @ai-context: v4 §7.4 统一产物层的 UI 呈现——组是唯一容器;本面板负责
5+
* 组列表/按组过滤/路由理由展示(REQ-198 可见)/一键改判与
6+
* 笔记移动(REQ-198 可改,修改即记忆)。
7+
* @ai-context: 受控过滤态由 NotesPage 持有(groupFilter),本组件自管组数据
8+
* 加载与改判交互;任何变更后回调 onChanged 触发笔记列表重载。
9+
*/
10+
import { useCallback, useEffect, useState } from "react";
11+
import { invoke } from "@tauri-apps/api/core";
12+
import type { GroupRouteReason, NoteGroup } from "../types";
13+
14+
/** 领域标签选项(与 Rust DomainKind 15 类同口径;改判下拉用) */
15+
const DOMAIN_OPTIONS: [string, string][] = [
16+
["economy", "经济管理"], ["programming", "编程开发"], ["math-science", "数学理科"],
17+
["language", "语言学习"], ["beauty", "化妆美妆"], ["fitness", "健身运动"],
18+
["law", "法律"], ["medical", "医学健康"], ["career", "职场技能"],
19+
["design", "设计创意"], ["music", "音乐"], ["handcraft", "手工"],
20+
["exam", "考试考证"], ["gaming", "游戏电竞"], ["psychology", "心理成长"],
21+
];
22+
23+
/** 组类别徽标(文案+配色) */
24+
function kindBadge(kind: string): { label: string; color: string } {
25+
if (kind === "course") return { label: "📚 课程", color: "#0f766e" };
26+
if (kind === "topic") return { label: "🏷 主题", color: "#7c3aed" };
27+
return { label: "📄 独立", color: "#6b7280" };
28+
}
29+
30+
/** 解析路由理由 JSON(损坏防御性回退空对象)。 */
31+
export function parseRouteReason(raw: string | null): GroupRouteReason {
32+
if (!raw) return {};
33+
try {
34+
const v = JSON.parse(raw);
35+
return typeof v === "object" && v !== null ? (v as GroupRouteReason) : {};
36+
} catch {
37+
return {};
38+
}
39+
}
40+
41+
interface Props {
42+
/** 当前过滤组(null=全部) */
43+
groupFilter: number | null;
44+
onGroupFilterChange: (id: number | null) => void;
45+
/** 当前选中笔记(提供"移入此组"快捷操作;null=无) */
46+
selectedNoteId: number | null;
47+
/** 组/笔记变更后的刷新回调(NotesPage 重载笔记列表) */
48+
onChanged: () => void;
49+
}
50+
51+
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged }: Props) {
52+
const [groups, setGroups] = useState<NoteGroup[]>([]);
53+
const [expandedId, setExpandedId] = useState<number | null>(null);
54+
const [status, setStatus] = useState("");
55+
// 改判表单态(展开区消费)
56+
const [overrideKind, setOverrideKind] = useState("standalone");
57+
const [overrideDomain, setOverrideDomain] = useState("");
58+
59+
const load = useCallback(async () => {
60+
try {
61+
const list = await invoke<NoteGroup[]>("list_note_groups", { terrain: null });
62+
setGroups(list);
63+
} catch (e) {
64+
setStatus(`组加载失败: ${e}`);
65+
}
66+
}, []);
67+
68+
useEffect(() => { void load(); }, [load]);
69+
70+
// ── 改判(REQ-198 修改即记忆)──
71+
const runOverride = async (g: NoteGroup) => {
72+
try {
73+
await invoke<boolean>("override_group_route", {
74+
id: g.id,
75+
kind: overrideKind,
76+
domainTag: overrideKind === "topic" ? overrideDomain || null : null,
77+
});
78+
setStatus("");
79+
await load();
80+
onChanged();
81+
} catch (e) {
82+
setStatus(`改判失败: ${e}`);
83+
}
84+
};
85+
86+
// ── 笔记移入/移出 ──
87+
const runMove = async (groupId: number | null) => {
88+
if (selectedNoteId == null) return;
89+
try {
90+
await invoke<boolean>("move_note_to_group", { noteId: selectedNoteId, groupId });
91+
setStatus("");
92+
await load();
93+
onChanged();
94+
} catch (e) {
95+
setStatus(`移动失败: ${e}`);
96+
}
97+
};
98+
99+
return (
100+
<div style={{ width: 230, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column" }}>
101+
<div style={{ padding: "10px 12px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
102+
<span>🗂 笔记组</span>
103+
<button onClick={() => void load()} style={{ marginLeft: "auto", fontSize: 13, cursor: "pointer" }} title="刷新组列表">⟳</button>
104+
</div>
105+
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
106+
{/* 全部(取消组过滤) */}
107+
<div
108+
onClick={() => onGroupFilterChange(null)}
109+
style={{
110+
padding: "7px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13,
111+
background: groupFilter === null ? "#f0fdfa" : "transparent",
112+
color: groupFilter === null ? "#0f766e" : "#374151", fontWeight: 500,
113+
}}
114+
>
115+
全部笔记
116+
</div>
117+
{groups.length === 0 && (
118+
<p style={{ fontSize: 12, color: "#9ca3af", padding: "12px 8px" }}>
119+
暂无笔记组——会话转笔记时自动归组
120+
</p>
121+
)}
122+
{groups.map((g) => {
123+
const badge = kindBadge(g.kind);
124+
const reason = parseRouteReason(g.routeReason);
125+
const active = groupFilter === g.id;
126+
const expanded = expandedId === g.id;
127+
return (
128+
<div key={g.id} style={{ marginTop: 4 }}>
129+
<div
130+
onClick={() => onGroupFilterChange(active ? null : g.id)}
131+
style={{
132+
padding: "7px 10px", borderRadius: 6, cursor: "pointer",
133+
background: active ? "#f0fdfa" : "transparent",
134+
border: reason.needsConfirm && !g.routeOverridden ? "1px dashed #f59e0b" : "1px solid transparent",
135+
}}
136+
>
137+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
138+
<span style={{ fontSize: 13, fontWeight: 500, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
139+
{g.name}
140+
</span>
141+
<span style={{ fontSize: 11, color: "#9ca3af" }}>{g.noteCount}</span>
142+
<span
143+
onClick={(e) => { e.stopPropagation(); setExpandedId(expanded ? null : g.id); setOverrideKind(g.kind); setOverrideDomain(g.domainTag ?? ""); }}
144+
style={{ fontSize: 11, color: "#6b7280", cursor: "pointer", padding: "0 3px" }}
145+
title="路由详情/改判"
146+
>
147+
{expanded ? "▾" : "▸"}
148+
</span>
149+
</div>
150+
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 3 }}>
151+
<span style={{ fontSize: 10, color: badge.color, background: "#f9fafb", borderRadius: 8, padding: "0 5px" }}>{badge.label}</span>
152+
{reason.needsConfirm && !g.routeOverridden && (
153+
<span style={{ fontSize: 10, color: "#b45309", background: "#fffbeb", borderRadius: 8, padding: "0 5px" }}>⚠ 待确认</span>
154+
)}
155+
{g.routeOverridden ? (
156+
<span style={{ fontSize: 10, color: "#6b7280", background: "#f3f4f6", borderRadius: 8, padding: "0 5px" }}>已改判</span>
157+
) : null}
158+
</div>
159+
</div>
160+
{/* 展开区:路由理由(可见)+ 改判/移动(可改)——REQ-198 */}
161+
{expanded && (
162+
<div style={{ margin: "4px 0 6px 10px", padding: 8, background: "#f9fafb", borderRadius: 6, fontSize: 12 }}>
163+
{(reason.reasons ?? []).length > 0 && (
164+
<div style={{ marginBottom: 6 }}>
165+
<div style={{ color: "#6b7280", marginBottom: 2 }}>路由理由:</div>
166+
{(reason.reasons ?? []).map((r, i) => (
167+
<div key={i} style={{ color: "#374151", paddingLeft: 6 }}>· {r}</div>
168+
))}
169+
</div>
170+
)}
171+
<div style={{ display: "flex", gap: 4, marginBottom: 6 }}>
172+
<select value={overrideKind} onChange={(e) => setOverrideKind(e.target.value)}
173+
style={{ fontSize: 11, padding: "2px 4px", border: "1px solid #e5e7eb", borderRadius: 4, flex: 1 }}>
174+
<option value="standalone">独立组</option>
175+
<option value="course">课程组</option>
176+
<option value="topic">主题组</option>
177+
</select>
178+
{overrideKind === "topic" && (
179+
<select value={overrideDomain} onChange={(e) => setOverrideDomain(e.target.value)}
180+
style={{ fontSize: 11, padding: "2px 4px", border: "1px solid #e5e7eb", borderRadius: 4, flex: 1 }}>
181+
<option value="">选择领域…</option>
182+
{DOMAIN_OPTIONS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
183+
</select>
184+
)}
185+
</div>
186+
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
187+
<button onClick={() => void runOverride(g)}
188+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}>
189+
✓ 改判
190+
</button>
191+
{selectedNoteId != null && (
192+
<>
193+
<button onClick={() => void runMove(g.id)}
194+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}>
195+
← 移入选中笔记
196+
</button>
197+
<button onClick={() => void runMove(null)}
198+
style={{ fontSize: 11, cursor: "pointer", padding: "2px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#fff" }}>
199+
移出组
200+
</button>
201+
</>
202+
)}
203+
</div>
204+
</div>
205+
)}
206+
</div>
207+
);
208+
})}
209+
</div>
210+
{status && <p style={{ padding: 8, fontSize: 12, color: "#dc2626" }}>{status}</p>}
211+
</div>
212+
);
213+
}

‎app/src/pages/NotesPage.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Note } from "../types";
1616
import NoteEditView from "../components/NoteEditView";
1717
import NoteListView, { parseTags } from "../components/NoteListView";
1818
import type { SortMode } from "../components/NoteListView";
19+
import NoteGroupPanel from "../components/NoteGroupPanel";
1920
import NoteReadingView from "../components/NoteReadingView";
2021
import ImagePreviewOverlay from "../components/ImagePreviewOverlay";
2122
import VersionPanel from "../components/VersionPanel";
@@ -32,6 +33,8 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
3233
const [keyword, setKeyword] = useState("");
3334
const [tagFilter, setTagFilter] = useState<string | null>(null);
3435
const [sortMode, setSortMode] = useState<SortMode>("updated-desc");
36+
// v0.11.0:组过滤(null=全部;NoteGroupPanel 受控)
37+
const [groupFilter, setGroupFilter] = useState<number | null>(null);
3538
const [selected, setSelected] = useState<Note | null>(null);
3639
const [status, setStatus] = useState("");
3740
// M3:编辑态
@@ -199,6 +202,12 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
199202
return Array.from(set).sort();
200203
}, [notes]);
201204

205+
// v0.11.0:组过滤在客户端生效(列表已全量加载;组切换零请求)
206+
const visibleNotes = useMemo(
207+
() => (groupFilter === null ? notes : notes.filter((n) => n.group_id === groupFilter)),
208+
[notes, groupFilter],
209+
);
210+
202211
// H3:辅助面板插槽——VersionPanel(版本时间线)+ EnrichPanel(知识补充),
203212
// 与 NoteEditView 同层的笔记详情区;key=note.id 切笔记重置面板内部任务态
204213
const auxPanels = selected ? (
@@ -210,9 +219,16 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
210219

211220
return (
212221
<div style={{ display: "flex", height: "calc(100vh - 56px)", minHeight: 0 }}>
222+
{/* ── 组侧栏(v0.11.0:统一产物层入口——过滤/路由可见可改)── */}
223+
<NoteGroupPanel
224+
groupFilter={groupFilter}
225+
onGroupFilterChange={setGroupFilter}
226+
selectedNoteId={selected?.id ?? null}
227+
onChanged={() => void load(keyword, tagFilter, sortMode)}
228+
/>
213229
{/* ── 左栏:搜索 + 标签过滤 + 排序 + 列表 ── */}
214230
<NoteListView
215-
notes={notes}
231+
notes={visibleNotes}
216232
keyword={keyword}
217233
tagFilter={tagFilter}
218234
sortMode={sortMode}

‎app/src/types/notes.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface Note {
4848
properties?: string | null;
4949
/** v0.10.0:固定标记(0=未固定,1=固定) */
5050
pin: number;
51+
/** v0.11.0(REQ-195):所属笔记组 id(null=未归组/旧数据) */
52+
group_id?: number | null;
5153
created_at: number;
5254
updated_at: number;
5355
}
@@ -62,6 +64,46 @@ export interface NewNote {
6264
tags?: string;
6365
/** v0.10.0:属性 JSON 对象 */
6466
properties?: string | null;
67+
/** v0.11.0:直接指定组(组视图内新建;缺省不归组) */
68+
group_id?: number | null;
69+
}
70+
71+
// ────────────────────────────────────────────────────────────
72+
// 笔记组类型(v0.11.0 REQ-195~198;Rust NoteGroup camelCase 契约)
73+
// ────────────────────────────────────────────────────────────
74+
75+
/** 组地形:container(课程/容器)/ feed(碎片) */
76+
export type GroupTerrain = "container" | "feed";
77+
78+
/** 组类别:course 课程组 / topic 主题组 / standalone 独立组 */
79+
export type GroupKind = "course" | "topic" | "standalone";
80+
81+
/** 路由理由 JSON(Rust 侧序列化;损坏时前端防御性回退) */
82+
export interface GroupRouteReason {
83+
/** own / topic / confirm / course */
84+
action?: string;
85+
/** true=信号冲突/低结构无领域——UI 高亮待确认 */
86+
needsConfirm?: boolean;
87+
reasons?: string[];
88+
}
89+
90+
/** 笔记组(统一产物层唯一容器,v4 §7.4) */
91+
export interface NoteGroup {
92+
id: number;
93+
name: string;
94+
terrain: GroupTerrain;
95+
kind: GroupKind;
96+
domainTag: string | null;
97+
/** route / series / manual */
98+
source: string;
99+
seriesKey: string | null;
100+
/** 路由理由 JSON 字符串(解析见 GroupRouteReason) */
101+
routeReason: string | null;
102+
/** 0=自动路由,1=用户已改判(修改即记忆) */
103+
routeOverridden: number;
104+
noteCount: number;
105+
createdAt: number;
106+
updatedAt: number;
65107
}
66108

67109
// ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)