refactor(feed): 分类提示条 per-article 挂载,贴在对应卡片下方

旧实现: 提示条放在工具条下面(全局一个,堆所有 category)
       用户反馈:标第二篇时弹在最前,不符合视觉逻辑

新实现: categoryPromptsByArticle: Map<articleId, CategoryPromptItem[]>
       标第 N 篇已读,提示条只挂到第 N 篇下面(紧贴)
       hide_read 模式下,卡片 leave 时提示条跟着 leave(共享 v-for key)

变更:
- state 从 categoryPrompts: CategoryPromptItem[] 改为 Map
- 工具条下全局提示条块删除
- TransitionGroup 内部 NCard 后加独立 v-for 渲染提示条 wrapper
- v-for 共享 key 用 a.id(跟 NCard 一致),hide_read 时联动 leave
- 删 prompt-* 动画(改用 TransitionGroup 共享的 name=card 动画)
- 配套 confirmMarkCategory/dismissPrompt/clearPromptsForArticle 签名加 articleId
This commit is contained in:
xiaji
2026-06-15 21:24:06 +08:00
parent 5eb331bf2d
commit 4f98fb8779

View File

@@ -24,20 +24,41 @@ const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(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<CategoryPromptItem[]>([])
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类
// Map<articleId, CategoryPromptItem[]> — 每篇文章独立挂自己的提示条
const categoryPromptsByArticle = ref<Map<number, CategoryPromptItem[]>>(new Map())
const pendingCategory = ref<string | null>(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 () => {
</NSpace>
<!--
分类批量已读提示条:
- 一篇文章可拆出 1-2 ( category 2 )
分类批量已读提示条(per-article):
- 标某条已读后,提示条出现在该卡片下方(紧贴)
- 1-2 ( category 2 ,unread_count > 0)
- 每行独立"全部已读 / 稍后再说"
- 8 秒自动消失 / 过滤变化时立即清掉
- 8 秒自动消失 / 过滤变化时清掉 / 卡片 leave 时跟随 leave
-->
<Transition name="prompt">
<div v-if="categoryPrompts.length > 0" class="feed-category-prompts">
<NAlert
v-for="p in categoryPrompts"
:key="p.category"
type="success"
:show-icon="false"
closable
@close="dismissPrompt(p.category)"
class="feed-category-prompt"
>
<template #header>
<NSpace align="center" :size="8" :wrap="true">
<NTag type="success" size="small" round :bordered="false"> 已读</NTag>
<NText>{{ p.category }}分类下还有 {{ p.unreadCount }} 24 小时未读</NText>
</NSpace>
</template>
<NSpace :size="8" style="margin-top: 8px">
<NButton
type="primary"
size="small"
round
:loading="pendingCategory === p.category"
:disabled="pendingCategory !== null"
@click="confirmMarkCategory(p.category)"
>
全部已读
</NButton>
<NButton size="small" round @click="dismissPrompt(p.category)">
稍后再说
</NButton>
</NSpace>
</NAlert>
</div>
</Transition>
<NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
@@ -698,6 +691,51 @@ onMounted(async () => {
</div>
</NSpace>
</NCard>
<!--
该文章对应的分类提示条(per-article 挂载,卡片**下方**):
- 独立的 v-for 循环,key 用 a.id 跟 NCard 共享,这样 hide_read 模式下
items.splice 移除文章时,提示条会跟 NCard 同步触发 leave 动画
- 内部 NAlert v-for 用 ${a.id}-${category} 作为 key,确保多个 category 时各自独立
- 用 v-show 而非 v-if:v-for 始终渲染 wrapper,内部 NAlert 按需显示
-->
<div
v-for="a in items"
v-show="!!categoryPromptsByArticle.get(a.id)?.length"
:key="`prompt-wrapper-${a.id}`"
class="feed-category-prompt-wrapper"
>
<NAlert
v-for="p in (categoryPromptsByArticle.get(a.id) || [])"
:key="`${a.id}-${p.category}`"
type="success"
:show-icon="false"
closable
@close="dismissPrompt(a.id, p.category)"
class="feed-category-prompt"
>
<template #header>
<NSpace align="center" :size="8" :wrap="true">
<NTag type="success" size="small" round :bordered="false">✓ 已读</NTag>
<NText>「{{ p.category }}」分类下还有 {{ p.unreadCount }} 条 24 小时未读</NText>
</NSpace>
</template>
<NSpace :size="8" style="margin-top: 8px">
<NButton
type="primary"
size="small"
round
:loading="pendingCategory === p.category"
:disabled="pendingCategory !== null"
@click="confirmMarkCategory(a.id, p.category)"
>
全部已读
</NButton>
<NButton size="small" round @click="dismissPrompt(a.id, p.category)">
稍后再说
</NButton>
</NSpace>
</NAlert>
</div>
</TransitionGroup>
<!-- 页码分页 -->
@@ -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;
}
</style>