Skip to content

Commit 7569871

Browse files
committed
fix(note): 手动序清理校验/选区区间与落点/划选泄漏(2026-09-05 审查批C)
1 parent fa1647a commit 7569871

6 files changed

Lines changed: 148 additions & 38 deletions

File tree

‎app/src-tauri/src/commands.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,8 @@ pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, St
470470
}
471471
let deleted = state.db.delete_note(id).map_err(|e| e.to_string())?;
472472
if deleted {
473+
// 审查 L6:删除即清手动序行(note_orders 无 FK——防孤儿行累积)
474+
let _ = state.db.purge_note_ids(&[id]);
473475
// v0.15:notes-images/{nid}/ 只属于该笔记——删除笔记即清空(尽力而为:
474476
// 失败不阻断(用户重试删除无意义时也允许残留,垃圾回收后续单独任务)
475477
let img_dir = state.data_dir.join("notes-images").join(id.to_string());

‎app/src-tauri/src/commands_groups.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ pub fn move_note_to_group(
180180
let ok = state.db.update_note_group(note_id, group_id).map_err(|e| e.to_string())?;
181181
// REQ-278:归组 = 笔记归属 + 组内容双变 → 双域广播(成功才发)
182182
if ok {
183+
// 审查 L6:移组即清旧 scope 手动序行(防"移出后移回复活旧序位")
184+
let _ = state.db.purge_note_ids(&[note_id]);
183185
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
184186
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
185187
}

‎app/src-tauri/src/commands_note_orders.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ pub fn note_order_save(state: State<'_, AppState>, scope: String, note_ids: Vec<
3838
if !note_ids.iter().all(|id| *id > 0 && seen.insert(*id)) {
3939
return Err("笔记 id 非法或重复".to_string());
4040
}
41+
// 审查 L6:归属校验——全部 id 必须当前属于该 scope(陈旧/跨组序整体拒绝)
42+
let ok = state
43+
.db
44+
.verify_scope_membership(&scope, &note_ids)
45+
.map_err(|e| e.to_string())?;
46+
if !ok {
47+
return Err("部分笔记不属于该排序范围——请先归组或刷新后再排序".to_string());
48+
}
4149
state.db.save_note_order(&scope, &note_ids).map_err(|e| e.to_string())?;
4250
// REQ-278:notes 域广播(排序变化 → 列表刷新)
4351
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);

‎app/src-tauri/src/db_note_orders.rs‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ impl Db {
4444
.lock()
4545
.unwrap_or_else(|poisoned| poisoned.into_inner());
4646
let tx = conn.transaction()?;
47-
tx.execute("DELETE FROM note_orders WHERE scope = ?1", [scope])?;
47+
tx.execute("DELETE FROM note_orders WHERE scope = ?1", rusqlite::params![scope])?;
4848
{
4949
let mut stmt =
5050
tx.prepare("INSERT INTO note_orders (scope, note_id, ord) VALUES (?1, ?2, ?3)")?;
5151
for (i, id) in note_ids.iter().enumerate() {
52-
stmt.execute([scope, &id.to_string(), &(i as i64).to_string()])?;
52+
// L12(审查):INTEGER 列按类型直绑(不再字符串化依赖列亲和)
53+
stmt.execute(rusqlite::params![scope, *id, i as i64])?;
5354
}
5455
}
5556
tx.commit()?;
@@ -63,6 +64,53 @@ impl Db {
6364
Ok(affected > 0)
6465
})
6566
}
67+
68+
/// 笔记删除/移组时清理其全部手动序行(审查 L6:无 FK——防孤儿行累积与
69+
/// "移出后移回复活旧序位";删除路径幂等,移组路径清除旧 scope 序)。
70+
pub fn purge_note_ids(&self, note_ids: &[i64]) -> Result<()> {
71+
self.with_conn(|conn| {
72+
for id in note_ids {
73+
conn.execute(
74+
"DELETE FROM note_orders WHERE note_id = ?1",
75+
rusqlite::params![*id],
76+
)?;
77+
}
78+
Ok(())
79+
})
80+
}
81+
82+
/// 归属校验(审查 L6):scope 要求每个 id 当前确属于该组/未分组——前端
83+
/// bug/并发移组后的陈旧序不得污染(不匹配 → Err,调用方整体拒绝)。
84+
pub fn verify_scope_membership(&self, scope: &str, note_ids: &[i64]) -> Result<bool> {
85+
self.with_conn(|conn| {
86+
let mut sql = String::from("SELECT id FROM notes WHERE ");
87+
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
88+
match scope.strip_prefix("g:") {
89+
Some(rest) => {
90+
sql.push_str("group_id = ?1 AND id IN (");
91+
params.push(Box::new(rest.to_string()));
92+
}
93+
None => {
94+
sql.push_str("group_id IS NULL AND id IN (");
95+
}
96+
}
97+
for (i, _) in note_ids.iter().enumerate() {
98+
if i > 0 { sql.push(','); }
99+
sql.push_str(&format!("?{}", i + params.len() + 1));
100+
params.push(Box::new(note_ids[i].to_string()));
101+
}
102+
sql.push(')');
103+
let mut stmt = conn.prepare(&sql)?;
104+
let rows = stmt.query_map(
105+
rusqlite::params_from_iter(params.iter().map(|b| b.as_ref())),
106+
|row| row.get::<_, i64>(0),
107+
)?;
108+
let found: Vec<i64> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
109+
let want: std::collections::HashSet<i64> = note_ids.iter().copied().collect();
110+
let got: std::collections::HashSet<i64> = found.into_iter().collect();
111+
Ok(got == want)
112+
})
113+
}
66114
}
67115

68116
#[cfg(test)]

‎app/src/components/NoteListRow.tsx‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,16 @@ interface Props {
3232
onModifierClick: (note: Note, ctrl: boolean, shift: boolean) => void;
3333
/** 拖拽行载荷(多选整组=选集 ids;未选中态=单行 id) */
3434
dragIds: number[];
35-
/** 行间落点(组内手动排序;父层判定) */
35+
/** 行间落点(组内手动排序;父层判定——仅树模式传入) */
3636
onDropOnRow?: (ids: number[], targetId: number, before: boolean) => void;
37-
/** 父层行间落点指示(视觉反馈) */
38-
dropIndicator?: "before" | "after" | null;
3937
onOpenSession: (sessionId: number) => void;
4038
/** v0.16.1:右键菜单打开(父层持有坐标/状态;原生菜单已全局禁用) */
4139
onContextMenu?: (e: React.MouseEvent, note: Note) => void;
4240
}
4341

4442
export default function NoteListRow({
4543
note, accent, openId, multiSelected, tagColors, onOpen, onModifierClick,
46-
dragIds, onDropOnRow, dropIndicator = null, onOpenSession, onContextMenu,
44+
dragIds, onDropOnRow, onOpenSession, onContextMenu,
4745
}: Props) {
4846
const tags = parseTags(note);
4947
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
@@ -76,7 +74,7 @@ export default function NoteListRow({
7674
let ids: number[] = [];
7775
try {
7876
const arr: unknown = JSON.parse(raw);
79-
if (Array.isArray(arr)) ids = arr.filter((x): x is number => typeof x === "number" && x > 0);
77+
if (Array.isArray(arr)) ids = arr.filter((x): x is number => typeof x === "number" && Number.isInteger(x) && x > 0);
8078
} catch { /* 兜底单 id */ }
8179
if (ids.length === 0) {
8280
const single = Number(e.dataTransfer.getData("text/note-id"));
@@ -103,9 +101,6 @@ export default function NoteListRow({
103101
borderLeft: `4px solid ${accent}`,
104102
cursor: "pointer",
105103
background: isOpen ? "#f0fdfa" : multiSelected ? "#eef2ff" : "transparent",
106-
boxShadow: dropIndicator === "before"
107-
? "inset 0 2px 0 #4f46e5"
108-
: dropIndicator === "after" ? "inset 0 -2px 0 #4f46e5" : "none",
109104
}}
110105
>
111106
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>

‎app/src/components/NoteListView.tsx‎

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export default function NoteListView({
104104
const [manualOrders, setManualOrders] = useState<Record<string, number[]>>({});
105105

106106
const visibleIdsRef = useRef<number[]>([]);
107+
// L5:行落点并发锁(防陈旧快照互覆)
108+
const dropBusyRef = useRef(false);
107109

108110
const clearSelection = useCallback(() => {
109111
setSelection(emptySelection());
@@ -235,7 +237,16 @@ export default function NoteListView({
235237
return out;
236238
}, [treeMode, grouped, groups, notes, groupFolds, applyManual, theme, groupFilter]);
237239

238-
const visibleOrder = useMemo(() => sections.flatMap((s) => s.items.map((n) => n.id)), [sections]);
240+
// 可见序(L1 审查:折叠组行不参与区间/划选——与渲染可见一致;折叠组头仍在)
241+
const visibleOrder = useMemo(() => {
242+
const out: number[] = [];
243+
for (const sec of sections) {
244+
const key = sec.groupId == null ? "none" : String(sec.groupId);
245+
if (groupFolds[key] === true) continue; // 折叠=行不可见,排除出选择语义
246+
for (const n of sec.items) out.push(n.id);
247+
}
248+
return out;
249+
}, [sections, groupFolds]);
239250
useEffect(() => { visibleIdsRef.current = visibleOrder; }, [visibleOrder]);
240251

241252
// ── 行交互 ──
@@ -246,16 +257,25 @@ export default function NoteListView({
246257
setAnchor(note.id);
247258
return;
248259
}
260+
// 审查 L4:普通单击=单选并打开——先清既有选集,anchor 恒指向本次点击
261+
if (selection.size > 0) clearSelection();
249262
onSelect(note);
250-
if (selection.size === 0) setAnchor(note.id);
251-
}, [selectionMode, onSelect, selection.size]);
263+
setAnchor(note.id);
264+
}, [selectionMode, onSelect, selection.size, clearSelection]);
252265

253266
const handleModifierClick = useCallback((note: Note, ctrl: boolean, shift: boolean) => {
254267
if (ctrl) {
255268
setSelection((cur) => toggleSelection(cur, note.id));
269+
// 审查 L4:Ctrl 后 anchor 指向本次点击行(即使该行被移除——Explorer 同款)
256270
setAnchor(note.id);
257271
} else if (shift) {
258-
setSelection((cur) => rangeSelection(cur, visibleIdsRef.current, anchor, note.id));
272+
if (anchor == null) {
273+
// 审查 L4:无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
274+
setSelection(new Set([note.id]));
275+
setAnchor(note.id);
276+
} else {
277+
setSelection((cur) => rangeSelection(cur, visibleIdsRef.current, anchor, note.id));
278+
}
259279
}
260280
}, [anchor]);
261281

@@ -285,37 +305,62 @@ export default function NoteListView({
285305
}
286306
}, [notes, manualOrders, saveOrder, onNoteMoved]);
287307

288-
/** 行间落点(同 scope 手动排序;自动排序组=仅归组语义已在 header 处理) */
308+
/** 行间落点(同 scope 手动排序;跨组归入目标组后按落点插入——L2 审查修正:
309+
* 先归组、后整表覆写;目标不可见/无 ord 尾部不再静默 no-op) */
289310
const handleDropOnRow = useCallback(async (ids: number[], targetId: number, before: boolean) => {
311+
// L3:平铺态(搜索/标签/非默认排序)禁排序拖拽——矩阵锁定规则
312+
if (!treeMode) return;
313+
if (dropBusyRef.current) return;
290314
const target = notes.find((n) => n.id === targetId);
291-
if (!target) return;
292-
const scope = scopeKey(target.group_id ?? null);
293-
const order = manualOrders[scope];
294-
const movers = ids.filter((id) => !order || order.includes(id) || notes.find((n) => n.id === id)?.group_id === target.group_id);
295-
const current = order ?? sections.find((s) => s.scope === scope)?.items.map((n) => n.id) ?? [];
296-
// 快照语义:首次拖=自动启用(当前可见序即快照)
297-
const list = (order ?? current).filter((id) => !movers.includes(id));
298-
const idx = list.indexOf(targetId);
299-
if (idx < 0) return;
300-
list.splice(before ? idx : idx + 1, 0, ...movers);
301-
// 跨组落点:先把非本组笔记归入目标组
302-
for (const id of ids) {
303-
const n = notes.find((x) => x.id === id);
304-
if (n && (n.group_id ?? null) !== (target.group_id ?? null)) {
315+
if (!target || ids.includes(targetId)) return;
316+
dropBusyRef.current = true;
317+
try {
318+
const targetGroup = target.group_id ?? null;
319+
const scope = scopeKey(targetGroup);
320+
const currentList = manualOrders[scope]
321+
?? sections.find((s) => s.scope === scope)?.items.map((n) => n.id)
322+
?? [];
323+
// 跨组 id:先归入目标组(await 顺序执行——同事务语义由命令层保证)
324+
const external = ids.filter((id) => {
325+
const n = notes.find((x) => x.id === id);
326+
return n && (n.group_id ?? null) !== targetGroup;
327+
});
328+
for (const id of external) {
305329
await invoke("move_note_to_group", { noteId: id, groupId: target.group_id });
306330
}
331+
// 落位(移出原序再插入;未启手排的 scope=快照语义自动启用)
332+
const list = currentList.filter((id) => !ids.includes(id));
333+
const idx = list.indexOf(targetId);
334+
if (idx < 0) {
335+
// 目标不可见(折叠/异常)——至少完成归组,不写序
336+
onNoteMoved?.();
337+
return;
338+
}
339+
list.splice(before ? idx : idx + 1, 0, ...ids);
340+
await saveOrder(scope, list);
341+
onNoteMoved?.();
342+
} catch (e) {
343+
console.warn("[notes] 行落点排序失败(部分操作可能已提交):", e);
344+
onNoteMoved?.();
345+
} finally {
346+
dropBusyRef.current = false;
307347
}
308-
await saveOrder(scope, list);
309-
onNoteMoved?.();
310-
}, [notes, manualOrders, sections, saveOrder, onNoteMoved]);
348+
}, [treeMode, notes, manualOrders, sections, saveOrder, onNoteMoved]);
311349

312-
/** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局序) */
350+
/** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。
351+
* L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理——
352+
* 不再有"窗口外释放后监听永久残留、悬停任意行改写选区"的泄漏。 */
313353
const startMarquee = useCallback((scope: string) => {
354+
// 折叠组行不可见——不提供划选起点(避免选中不可见数据)
355+
const foldKey = scope === "none" ? "none" : scope.slice(2);
356+
if (groupFolds[foldKey] === true) return;
314357
const section = sections.find((s) => s.scope === scope);
315358
if (!section || section.items.length === 0) return;
316359
const startGlobal = visibleOrder.indexOf(section.items[0].id);
317360
if (startGlobal < 0) return;
318-
const onMove = (e: PointerEvent) => {
361+
362+
let raf = 0;
363+
const hit = (e: PointerEvent) => {
319364
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
320365
const rowEl = el?.closest<HTMLElement>('[id^="note-row-"]');
321366
if (!rowEl) return;
@@ -326,13 +371,23 @@ export default function NoteListView({
326371
const hi = Math.max(startGlobal, gi);
327372
setSelection(new Set(visibleOrder.slice(lo, hi + 1)));
328373
};
329-
const onUp = () => {
374+
const onMove = (e: PointerEvent) => {
375+
if (e.buttons === 0) { cleanup(); return; }
376+
if (raf) return;
377+
raf = requestAnimationFrame(() => { raf = 0; hit(e); });
378+
};
379+
const cleanup = () => {
380+
if (raf) cancelAnimationFrame(raf);
330381
window.removeEventListener("pointermove", onMove);
331-
window.removeEventListener("pointerup", onUp);
382+
window.removeEventListener("pointerup", cleanup);
383+
window.removeEventListener("pointercancel", cleanup);
384+
window.removeEventListener("blur", cleanup);
332385
};
333386
window.addEventListener("pointermove", onMove);
334-
window.addEventListener("pointerup", onUp);
335-
}, [sections, visibleOrder]);
387+
window.addEventListener("pointerup", cleanup);
388+
window.addEventListener("pointercancel", cleanup);
389+
window.addEventListener("blur", cleanup);
390+
}, [sections, visibleOrder, groupFolds]);
336391

337392
const rowAccent = (n: Note) => paletteHex(noteColors?.[n.id] ?? null, theme);
338393

0 commit comments

Comments
 (0)