diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index f25aed5..014ce64 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -24,20 +24,41 @@ const loading = ref(false) // 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素) const pendingRemoval = ref>(new Set()) -// === 分类批量已读提示条 === +// === 分类批量已读提示条(per-article) === // 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数"; -// 大于 0 的 category 各显示一行提示,带独立"全部已读 / 稍后再说"按钮 +// 大于 0 的 category 各显示一行提示,**紧贴该文章卡片下方** // 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出 -// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 重置或清空) +// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 移除该 article 的提示条) +// hide_read 模式下,卡片 leave 时该 article 对应的提示条也跟着 leave(共享 setTimeout) type CategoryPromptItem = { category: string unreadCount: number - triggeredById: number } -const categoryPrompts = ref([]) -const pendingCategory = ref(null) // 正在请求"全部已读"的分类 +// Map — 每篇文章独立挂自己的提示条 +const categoryPromptsByArticle = ref>(new Map()) +const pendingCategory = ref(null) // 正在请求"全部已读"的分类(category 名) let categoryPromptTimer: number | null = null +// 移除某篇文章的所有提示条(8 秒超时 / 确认后 / 过滤变化) +function clearPromptsForArticle(articleId: number) { + if (categoryPromptsByArticle.value.has(articleId)) { + const next = new Map(categoryPromptsByArticle.value) + next.delete(articleId) + categoryPromptsByArticle.value = next + } +} + +// 全清 +function clearAllPrompts() { + if (categoryPromptsByArticle.value.size > 0) { + categoryPromptsByArticle.value = new Map() + } + if (categoryPromptTimer !== null) { + clearTimeout(categoryPromptTimer) + categoryPromptTimer = null + } +} + // === 页码分页(替代原来的 cursor 无限滚动)=== const page = ref(1) const pageSize = ref(50) @@ -208,19 +229,18 @@ async function maybePromptCategoryRead(a: ArticleListItem) { .map((c) => ({ category: c.category, unreadCount: c.unread_count, - triggeredById: a.id, })) if (newPrompts.length === 0) return - // 同一 category 已存在则替换(更新计数),不重复 - const map = new Map(categoryPrompts.value.map((p) => [p.category, p])) - for (const p of newPrompts) map.set(p.category, p) - categoryPrompts.value = Array.from(map.values()) + // 把提示条挂到被标记的那篇文章下面(Map 替换触发响应式) + const next = new Map(categoryPromptsByArticle.value) + next.set(a.id, newPrompts) + categoryPromptsByArticle.value = next - // 重置 8 秒自动消失 + // 8 秒后自动移除该文章的提示条(无操作就消失) if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer) categoryPromptTimer = window.setTimeout(() => { - categoryPrompts.value = [] + clearPromptsForArticle(a.id) categoryPromptTimer = null }, 8000) } catch (e: any) { @@ -230,8 +250,8 @@ async function maybePromptCategoryRead(a: ArticleListItem) { } } -// 用户点头部"全部已读":调 markCategory + 乐观滑出 + 关闭该行提示 -async function confirmMarkCategory(category: string) { +// 用户点提示条"全部已读":调 markCategory + 乐观滑出 + 关闭该条提示 +async function confirmMarkCategory(articleId: number, category: string) { if (pendingCategory.value) return // 防重入 pendingCategory.value = category try { @@ -262,7 +282,8 @@ async function confirmMarkCategory(category: string) { if (total.value > 0) total.value -= 1 }, delay) } - dismissPrompt(category) + // 关闭该 article 的整组提示(不只关 category — 既然"全部已读"了,这一片都不需要了) + clearPromptsForArticle(articleId) message.success(`已将 ${resp.marked} 条「${category}」标记为已读`) } catch (e: any) { message.error(e?.response?.data?.title || '操作失败') @@ -271,10 +292,20 @@ async function confirmMarkCategory(category: string) { } } -// 关闭某一行(稍后再说 / 自动消失 / 已被 confirm 后调用) -function dismissPrompt(category: string) { - categoryPrompts.value = categoryPrompts.value.filter((p) => p.category !== category) - if (categoryPrompts.value.length === 0 && categoryPromptTimer !== null) { +// 关闭某条提示(稍后再说 / × 按钮)— 输入是 articleId + category +function dismissPrompt(articleId: number, category: string) { + const cur = categoryPromptsByArticle.value.get(articleId) + if (!cur) return + const next = cur.filter((p) => p.category !== category) + const map = new Map(categoryPromptsByArticle.value) + if (next.length === 0) { + map.delete(articleId) + } else { + map.set(articleId, next) + } + categoryPromptsByArticle.value = map + // 如果整组提示都关了,清掉 timer + if (categoryPromptsByArticle.value.size === 0 && categoryPromptTimer !== null) { clearTimeout(categoryPromptTimer) categoryPromptTimer = null } @@ -297,12 +328,8 @@ function resetToFirstPage() { page.value = 1 load() // 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉 - if (categoryPrompts.value.length > 0) { - categoryPrompts.value = [] - if (categoryPromptTimer !== null) { - clearTimeout(categoryPromptTimer) - categoryPromptTimer = null - } + if (categoryPromptsByArticle.value.size > 0) { + clearAllPrompts() } } @@ -428,46 +455,12 @@ onMounted(async () => { - -
- - - - - 全部已读 - - - 稍后再说 - - - -
-
@@ -698,6 +691,51 @@ onMounted(async () => { + +
+ + + + + 全部已读 + + + 稍后再说 + + + +
@@ -1044,11 +1082,13 @@ onMounted(async () => { transition: transform 0.35s ease; } -/* === 分类批量已读提示条 === +/* === 分类批量已读提示条(per-article,紧贴卡片下方)=== - 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏 - - 出现/消失:opacity + max-height 配合,跟卡片滑出同节奏 + - wrapper 自身不带动画,内部 NAlert 走 card 动画(因为是 TransitionGroup 子节点) + - wrapper 与 NCard 之间有视觉分隔(margin-top),但因为它在 TransitionGroup 内 + 共享 name="card" 的 leave 动画,卡片滑出时 wrapper 同步滑出,看起来"提示条跟着卡片走" */ -.feed-category-prompts { +.feed-category-prompt-wrapper { display: flex; flex-direction: column; gap: 8px; @@ -1063,24 +1103,4 @@ onMounted(async () => { font-size: 13px; font-weight: 500; } - -/* 提示条进入/离开(整组) */ -.prompt-enter-active, -.prompt-leave-active { - transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.55, 0, 0.55, 0.2), - max-height 0.3s ease; - overflow: hidden; -} -.prompt-enter-from, -.prompt-leave-to { - opacity: 0; - transform: translateY(-6px); - max-height: 0 !important; -} -.prompt-enter-to, -.prompt-leave-from { - opacity: 1; - transform: translateY(0); - max-height: 200px; -} \ No newline at end of file