Skip to content

fix(web): 书签删除 UI 与删除安全性修复#22

Open
youxuezhe7 wants to merge 14 commits into
jihe520:mainfrom
youxuezhe7:main
Open

fix(web): 书签删除 UI 与删除安全性修复#22
youxuezhe7 wants to merge 14 commits into
jihe520:mainfrom
youxuezhe7:main

Conversation

@youxuezhe7
Copy link
Copy Markdown

Summary

本 PR 为书签添加了安全可靠的删除功能,并修复了相关 UI 问题。

功能新增

  • 书签删除 UI:卡片下拉菜单增加删除选项,详情页增加删除按钮,带确认对话框和 loading spinner

UI 修复

  • 卡片"删除"跳转问题:Radix DropdownMenu 渲染在 portal 中,点击菜单项后 portal 卸载导致 click 事件穿透到卡片 Link 触发跳转。通过在 Link 上添加 onClick 守卫(dropdown/dialog 打开时 preventDefault)解决
  • 长标题挤走按钮:详情页标题使用 line-clamp-1(底层 -webkit-box)破坏了 flex 布局,min-w-0 失效。改用 truncate 在 flex 容器中正确截断
  • 确认对话框标题过长DialogDescription 中的书签标题(完整 URL)撑爆对话框,按钮被挤出屏幕。添加 line-clamp-2 break-all 约束文本

删除安全性修复(五轮 Codex 审查迭代)

存储清理:

  • 删除文件书签时同步清理关联的 Vercel Blob 对象,避免文件孤儿和持续计费
  • 先删 DB 再删 Blob(确保 DB 失败时文件完好,比先删 Blob 后 DB 失败导致数据丢失更安全)
  • Blob 删除带 5 秒超时并 await 完成,防止进程退出导致的清理丢失

并发安全:

  • 添加 pendingDeletes 追踪机制,跟踪正在删除的书签 ID
  • 乐观删除采用精确回滚(仅恢复失败书签),不再全量快照回滚(防止并发删除时复活已成功的书签)
  • 回滚失败时保存删除前快照,refetch 也失败时恢复快照
  • DELETE 404 视为幂等成功

缓存一致性:

  • fetchBookmarks 的 4 个写入点(缓存命中/网络结果的 append/replace)均过滤 pendingDeletes
  • 缓存层存储过滤后的书签列表,防止已删 ID 通过缓存复活

修改文件 (apps/web/)

  • components/bookmark-card.tsx — 卡片删除菜单 + Link 守卫
  • components/bookmark-grid.tsx — 列表删除菜单
  • components/delete-bookmark-dialog.tsx — 删除确认对话框(新增)
  • hooks/use-bookmark-delete.ts — 删除 hook(新增)
  • stores/bookmark-store.ts — 删除乐观更新 + 并发安全
  • app/(app)/bookmark/[id]/bookmark-detail-client.tsx — 详情页删除按钮 + 标题截断
  • app/api/bookmarks/[id]/route.ts — DELETE endpoint + Blob 清理

本 PR 由 Claude Code + DeepSeek V4 Pro 编写,Codex + GPT-5.5 审查

youxuezhe7 and others added 14 commits April 29, 2026 16:24
… fix

- 详情页删除按钮添加 Loader2 动画
- useBookmarkDelete 改用 getState() 读取最新 folders,避免闭包过期
- 移除 DropdownMenuContent 无效的 onClick preventDefault(Portal 渲染)
- void -> .catch() 修复 biome noVoid lint 错误

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
点击书签卡片的 ··· 下拉菜单中的"删除"选项时,Radix DropdownMenu
关闭后事件链会触发父级 <Link> 导航到详情页,而非弹出删除确认对话框。
修复:将 DropdownMenu 改为受控模式,在删除项的 onSelect 中
调用 event.preventDefault() 阻止 Radix 默认关闭行为,
改为手动关闭菜单后再打开对话框。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
详情页 header 中标题过长时会把右侧删除按钮挤出屏幕。
修复:标题 span 加 min-w-0 使 line-clamp-1 在 flex 中生效、
返回按钮加 shrink-0 防止被压缩、右侧按钮区加 shrink-0 保护。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… dropdown click-through

Two previous fixes were incomplete:

1. bookmark-detail-client: line-clamp-1 sets display: -webkit-box, which
   breaks flex layout and nullifies min-w-0. Switch to truncate which
   properly constrains text in a flex row.

2. bookmark-card: Radix DropdownMenu renders in a portal. Calling
   setDropdownOpen(false) immediately unmounts the portal, causing
   remaining pointer/click events to pass through to the card Link
   underneath. Delay close by 100ms to let the click cycle complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace setTimeout-based approach with direct onClick guard on the
card Link. When the dropdown menu, delete dialog, or move dialog
is open, prevent navigation via e.preventDefault(). This is more
reliable than timing-based fixes for the portal click-through issue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…reen

The confirmation dialog description uses the full bookmark title.
When the title is a long URL with no word breaks, it stretches the
dialog horizontally, pushing the Cancel and Delete buttons out of
view. Add line-clamp-2 and break-all to constrain the text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rent delete rollback

Two fixes from Codex adversarial review:

1. [HIGH] DELETE handler now calls @vercel/blob del() on existing.fileUrl
   before removing the DB row. Blob failure is caught and logged so it
   never blocks DB cleanup. Orphaned public files are no longer left
   behind when deleting file-type bookmarks.

2. [MEDIUM] Rewrite deleteBookmark to use surgical rollback instead of
   full-snapshot rollback. Pending deletes are tracked per-id so
   concurrent deletes don't cross-contaminate. DELETE 404 is treated as
   idempotent success. On failure only the specific bookmark is restored
   rather than the entire list, pagination, and cache.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes from Codex review round 2:

1. [HIGH] Delete DB row before Blob: If DB fails, bookmark and file
   both survive. If DB succeeds but Blob fails, the file is orphaned
   (console.error logged) but no data loss occurs.

2. [MEDIUM] Filter pendingDeletes from all fetchBookmarks write paths
   (cache hit append/replace and network success append/replace).
   Also re-filter the current list in deleteBookmark success path
   before clearing the pending flag, preventing concurrent refreshes
   from re-inserting a bookmark that was just deleted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes from Codex review round 3:

1. [HIGH] Blob deletion no longer blocks the DELETE response. Use
   del().catch() (fire-and-forget) instead of await, so a degraded
   Blob service cannot make the client see a failed DELETE after the
   DB row is already gone.

2. [MEDIUM] Replace precision-rollback with refetch on failure. The
   old approach spliced a captured bookmark back into whatever list
   was current, violating filter invariants if the user changed views
   during the request. Now on failure we clear cache, remove the
   pending flag, and call fetchBookmarks() to reconcile with the
   server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…imeout

Two fixes from Codex review round 4:

1. [HIGH] Cache now stores filtered bookmarks (pendingDeletes removed
   before writing to cache), preventing deleted IDs from re-entering
   the cache layer. Previously the raw server response was cached
   unfiltered, so a stale response could resurrect a deleted bookmark
   on the next cache hit within the 2-minute TTL.

2. [MEDIUM] Blob deletion wrapped in Promise.race with a 5-second
   timeout. Prevents a degraded Blob service from keeping the fire-
   and-forget promise dangling indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes from Codex review round 5:

1. [HIGH] Await Blob deletion with 5s timeout instead of fire-and-
   forget. Ensures the process cannot exit before Blob cleanup
   completes, eliminating the case where a terminated runtime leaves
   the file permanently orphaned without any completion signal.

2. [MEDIUM] Save pre-delete snapshot (bookmarks + pagination) before
   optimistic removal. On rollback, await fetchBookmarks(); if the
   refetch also fails, restore the snapshot so the UI doesn't stay in
   a false-deleted state when the server didn't actually delete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Promise.race types are compatible so the directive caused a
TypeScript error during build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… folder reorder transaction

- Replace session!.user!.id non-null assertions in 8 API routes with requireApiSession() helper, returning 401 instead of 500 on invalid sessions
- Wrap folder reorder batch UPDATE in db.transaction() to prevent partial failures
- Add composite index (chatId, createdAt) on message table for query performance

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant