diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index ecbc4867..e9f717ca 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -281,12 +281,81 @@ export const move = mutation({ .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); + + // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, + // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 + // 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。 const ts = nowIso(); - await ctx.db.patch(doc._id, { - parent_id: args.parentId, - sort_order: args.sortOrder, - updated_at: ts, - }); + + const compareDocOrder = (a: any, b: any) => { + const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER; + const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + // created_at 为 ISO 字符串,按字典序比较即可。 + return String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")); + }; + + const clampIndex = (raw: number, max: number) => { + const n = Number.isFinite(raw) ? Math.floor(raw) : 0; + if (n < 0) return 0; + if (n > max) return max; + return n; + }; + + const fetchSiblings = async (parentId: string | null) => { + const siblings = await ctx.db + .query("documents") + .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", doc.workspace_id).eq("parent_id", parentId)) + .collect(); + + return siblings + .filter((d) => d.user_id === userId) + .filter((d) => d.deleted_at == null) + .sort(compareDocOrder); + }; + + const applyOrder = async (ordered: any[], parentId: string | null, movedId?: any) => { + for (let i = 0; i < ordered.length; i += 1) { + const item = ordered[i]; + const nextSortOrder = i; + const nextParentId = parentId; + const patch: Record = {}; + + if ((item.parent_id ?? null) !== nextParentId) patch.parent_id = nextParentId; + if ((item.sort_order ?? null) !== nextSortOrder) patch.sort_order = nextSortOrder; + + // 说明:只强制更新被移动节点的 updated_at,避免拖拽一次导致大量节点更新时间变化。 + if (movedId && item._id === movedId) patch.updated_at = ts; + + if (Object.keys(patch).length > 0) { + // eslint-disable-next-line no-await-in-loop + await ctx.db.patch(item._id, patch); + } + } + }; + + const fromParentId = (doc.parent_id ?? null) as string | null; + const toParentId = args.parentId; + + if (fromParentId === toParentId) { + const siblings = await fetchSiblings(toParentId); + const list = siblings.filter((d) => d.id !== doc.id); + const position = clampIndex(args.sortOrder, list.length); + list.splice(position, 0, doc); + await applyOrder(list, toParentId, doc._id); + return { ok: true, updated_at: ts }; + } + + // 先重排原父节点,确保移动后原列表连续。 + const oldSiblings = (await fetchSiblings(fromParentId)).filter((d) => d.id !== doc.id); + await applyOrder(oldSiblings, fromParentId); + + // 再重排目标父节点,把节点插入到目标位置。 + const newSiblings = (await fetchSiblings(toParentId)).filter((d) => d.id !== doc.id); + const position = clampIndex(args.sortOrder, newSiblings.length); + newSiblings.splice(position, 0, doc); + await applyOrder(newSiblings, toParentId, doc._id); + return { ok: true, updated_at: ts }; }, });