feat(feed): 按分类批量已读 + 标已读后弹出分类提示条

后端:
- POST /me/reads/by-category:按 category + time window 批量标已读
- GET /me/reads/category-count:查多个分类的 24h 未读数

前端:
- 标已读后,查该文章前 2 个 category 的 24h 未读数
- 每个有未读的 category 显示一行提示(独立全部已读/稍后再说按钮)
- 全部已读走乐观更新,命中的 article 走累计 delay 滑出
- 过滤变化时清掉提示(上下文变了)
- 8 秒自动消失
This commit is contained in:
xiaji
2026-06-15 20:36:06 +08:00
parent 43afa8c56c
commit 26f5a16530
3 changed files with 445 additions and 6 deletions

View File

@@ -3,7 +3,7 @@ import { computed, h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
NPagination, NAutoComplete, useMessage,
NPagination, NAutoComplete, NAlert, useMessage,
} from 'naive-ui'
import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
import { searchApi, type SearchKeyword } from '@/api/search'
@@ -24,6 +24,20 @@ const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(new Set())
// === 分类批量已读提示条 ===
// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数";
// 大于 0 的 category 各显示一行提示,带独立"全部已读 / 稍后再说"按钮
// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出
// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 重置或清空)
type CategoryPromptItem = {
category: string
unreadCount: number
triggeredById: number
}
const categoryPrompts = ref<CategoryPromptItem[]>([])
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类
let categoryPromptTimer: number | null = null
// === 页码分页(替代原来的 cursor 无限滚动)===
const page = ref(1)
const pageSize = ref(50)
@@ -164,6 +178,13 @@ async function toggleRead(a: ArticleListItem) {
}, 360)
}
}
// === 新增:刚标为已读 → 查该文章前 2 个 category 的 24h 未读数 ===
// unmark 路径不触发(用户反悔了,不该再骚扰);hide_read 模式仍可触发
// (滑出动画只影响当前这一条,提示条展示的是"这个分类下还有别的未读")
if (!wasRead && a.category) {
await maybePromptCategoryRead(a)
}
} catch (e: any) {
// 失败回滚
a.is_read = wasRead
@@ -171,6 +192,94 @@ async function toggleRead(a: ArticleListItem) {
}
}
// 查 a 的前 2 个 category 的 24h 未读数,有未读就追加 / 替换提示条
async function maybePromptCategoryRead(a: ArticleListItem) {
const cats = splitCategory(a.category).slice(0, 2)
if (cats.length === 0) return
try {
const counts = await readsApi.countByCategories(cats, {
window_hours: 24,
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
q: q.value || undefined,
})
// 只保留 unread_count > 0 的
const newPrompts: CategoryPromptItem[] = counts
.filter((c) => c.unread_count > 0)
.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())
// 重置 8 秒自动消失
if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer)
categoryPromptTimer = window.setTimeout(() => {
categoryPrompts.value = []
categoryPromptTimer = null
}, 8000)
} catch (e: any) {
// 计数失败不影响主流程,静默
// eslint-disable-next-line no-console
console.debug('category count failed:', e?.message)
}
}
// 用户点头部"全部已读":调 markCategory + 乐观滑出 + 关闭该行提示
async function confirmMarkCategory(category: string) {
if (pendingCategory.value) return // 防重入
pendingCategory.value = category
try {
const resp = await readsApi.markCategory({
category,
scope: 'filtered_unread',
window_hours: 24,
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
q: q.value || undefined,
})
// 把命中的 article 在 items 里全部 is_read=true
const ids = new Set(resp.article_ids)
for (const item of items.value) {
if (ids.has(item.id)) item.is_read = true
}
// 走 hide_read 模式下的滑出(累计 delay,逐个错开,避免 30 个 setTimeout 同时触发视觉抖)
let i = 0
for (const id of resp.article_ids) {
const idx = items.value.findIndex((x) => x.id === id)
if (idx < 0) continue
pendingRemoval.value.add(id)
const delay = 360 + i * 20
i++
setTimeout(() => {
const k = items.value.findIndex((x) => x.id === id)
if (k >= 0) items.value.splice(k, 1)
pendingRemoval.value.delete(id)
if (total.value > 0) total.value -= 1
}, delay)
}
dismissPrompt(category)
message.success(`已将 ${resp.marked} 条「${category}」标记为已读`)
} catch (e: any) {
message.error(e?.response?.data?.title || '操作失败')
} finally {
pendingCategory.value = null
}
}
// 关闭某一行(稍后再说 / 自动消失 / 已被 confirm 后调用)
function dismissPrompt(category: string) {
categoryPrompts.value = categoryPrompts.value.filter((p) => p.category !== category)
if (categoryPrompts.value.length === 0 && categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = null
}
}
async function loadSources() {
sources.value = await sourcesApi.list()
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
@@ -187,6 +296,14 @@ function onPageChange(p: number) {
function resetToFirstPage() {
page.value = 1
load()
// 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉
if (categoryPrompts.value.length > 0) {
categoryPrompts.value = []
if (categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = null
}
}
}
function open(a: ArticleListItem) {
@@ -310,6 +427,48 @@ onMounted(async () => {
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
</NSpace>
<!--
分类批量已读提示条:
- 一篇文章可拆出 1-2 ( category 2 )
- 每行独立"全部已读 / 稍后再说"
- 8 秒自动消失 / 过滤变化时立即清掉
-->
<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" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
@@ -884,4 +1043,44 @@ onMounted(async () => {
.card-move {
transition: transform 0.35s ease;
}
/* === 分类批量已读提示条 ===
- 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏
- 出现/消失:opacity + max-height 配合,跟卡片滑出同节奏
*/
.feed-category-prompts {
display: flex;
flex-direction: column;
gap: 8px;
}
.feed-category-prompt {
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
border-left: 3px solid var(--color-primary, #5b86e5);
border-radius: 6px;
padding: 10px 12px;
}
.feed-category-prompt :deep(.n-alert__header) {
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>