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

@@ -1,11 +1,13 @@
"""/me 当前用户信息 + 翻译配额 + 已读文章。""" """/me 当前用户信息 + 翻译配额 + 已读文章 + 按分类批量已读"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, select from sqlalchemy import and_, delete, func, not_, or_, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -13,6 +15,7 @@ from app.core.deps import get_current_user
from app.database import get_session from app.database import get_session
from app.models.article import Article from app.models.article import Article
from app.models.article_read import ArticleRead from app.models.article_read import ArticleRead
from app.models.source import Source
from app.models.user import User from app.models.user import User
from app.redis_client import get_redis from app.redis_client import get_redis
@@ -95,7 +98,6 @@ async def mark_read(
if not exists: if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
# 用 INSERT ... ON CONFLICT DO NOTHING (PG 原生)— 等价 upsert 跳过 # 用 INSERT ... ON CONFLICT DO NOTHING (PG 原生)— 等价 upsert 跳过
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(ArticleRead).values(user_id=user.id, article_id=article_id).on_conflict_do_nothing( stmt = pg_insert(ArticleRead).values(user_id=user.id, article_id=article_id).on_conflict_do_nothing(
index_elements=["user_id", "article_id"] index_elements=["user_id", "article_id"]
) )
@@ -142,3 +144,208 @@ async def list_reads(
rows = (await session.execute(stmt)).all() rows = (await session.execute(stmt)).all()
ids = [r[0] for r in rows] ids = [r[0] for r in rows]
return ReadListResponse(article_ids=ids, total=len(ids)) return ReadListResponse(article_ids=ids, total=len(ids))
# === 按分类批量已读(per-user) ===
# 用例:用户在 Feed 标了一条《xxx》为已读,该文章 category 含 "美国" "社会" 两个 tag,
# 前端会同时查这两个分类下"24 小时未读数"展示提示条,点头部的"全部已读"调本接口。
# 设计要点:
# - category 用 ilike 模糊匹配(Article.category 是逗号分隔字符串 '美国,社会,紧急')
# - 默认遵守当前 Feed 过滤(关键词/源)— scope=filtered_unread,避免误杀
# - 时间窗默认 24h(防"老新闻刷不完"的提示)
# - 批量插入用 ON CONFLICT DO NOTHING,幂等
# - 返回 article_ids 给前端做乐观滑出动画
def _unread_subquery(user_id: int):
"""未读文章 id 子查询(per-user)— 复用 articles.py 的 NOT EXISTS 模式。"""
return (
select(ArticleRead.article_id)
.where(ArticleRead.user_id == user_id, ArticleRead.article_id == Article.id)
.exists()
)
def _escape_like(term: str) -> str:
"""转义 ilike 的元字符,避免 category 含 % / _ 时被当通配符。"""
return term.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
class CategoryReadRequest(BaseModel):
category: str = Field(..., min_length=1, max_length=32, description="要批量已读的分类 tag")
scope: Literal["all_unread", "filtered_unread"] = Field(
default="filtered_unread",
description="过滤范围:filtered_unread=遵守 sources/q;all_unread=忽略",
)
window_hours: int = Field(
default=24, ge=1, le=168, description="只标 published_at 在最近 N 小时内的未读"
)
sources: list[str] | None = Field(default=None, description="源 slug 列表,过滤范围")
q: str | None = Field(default=None, description="关键词过滤(标题/正文模糊)")
class CategoryReadResponse(BaseModel):
category: str
matched: int # 命中的未读数
marked: int # 实际新标已读数(去重后)
article_ids: list[int] # 给前端做滑出动画
class CategoryCountItem(BaseModel):
category: str
unread_count: int
window_hours: int
def _build_category_filter(
category: str,
window_hours: int,
user_id: int,
sources: list[str] | None = None,
q: str | None = None,
):
"""构造"分类 + 时间窗 + 未读"三合一 filter。
- category 用 ilike 模糊匹配(逗号分隔串中含此 tag 即可)
- window_hours 通过 published_at 过滤
- 用 NOT EXISTS 排除当前用户已读
- sources / q 可选,跟 articles.py 列表查询口径一致
"""
pattern = f"%{_escape_like(category)}%"
# 用 text() 拼 interval,避免 make_interval 的 PG 函数参数绑定在某些
# SQLAlchemy 版本下的兼容性问题。window_hours 是 int,只来自我们自己的
# endpoint,不是用户原始字符串,安全。
interval_sql = text(f"interval '{int(window_hours)} hours'")
filters = [
Article.category.ilike(pattern, escape="\\"),
Article.published_at >= func.now() - interval_sql,
not_(_unread_subquery(user_id)),
]
if sources:
slugs = [s.strip() for s in sources if s.strip()]
if slugs:
filters.append(Source.slug.in_(slugs))
if q and q.strip():
like = f"%{q.strip()}%"
filters.append(
or_(
Article.title.ilike(like),
Article.body_text.ilike(like),
Article.title_zh.ilike(like),
Article.body_zh_text.ilike(like),
Article.summary_zh.ilike(like),
)
)
return filters
@router.post("/reads/by-category", response_model=CategoryReadResponse)
async def mark_category_read(
body: CategoryReadRequest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""按分类批量已读。
- 默认 scope=filtered_unread:只在当前 Feed 过滤范围内标
- 时间窗默认 24h:防"老新闻刷不完"
- 幂等:已读的不重复处理
"""
cat = body.category.strip()
if not cat:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "category 不能为空")
# scope=all_unread 时,无视 sources/q(传了也忽略,语义更清晰)
if body.scope == "all_unread":
eff_sources: list[str] | None = None
eff_q: str | None = None
else:
eff_sources = body.sources
eff_q = body.q
filters = _build_category_filter(
category=cat,
window_hours=body.window_hours,
user_id=user.id,
sources=eff_sources,
q=eff_q,
)
# 1. 查命中 id 列表(返回给前端)
id_stmt = select(Article.id)
# sources 过滤需要 join
if eff_sources:
id_stmt = id_stmt.join(Source, Source.id == Article.source_id)
for f in filters:
id_stmt = id_stmt.where(f)
article_ids = [r[0] for r in (await session.execute(id_stmt)).all()]
if not article_ids:
return CategoryReadResponse(category=cat, matched=0, marked=0, article_ids=[])
# 2. 批量插入 article_reads(ON CONFLICT DO NOTHING,幂等)
# 分批:防御性,避免单次 VALUES 太多;500 是经验值,PG 完全能吃更大
BATCH = 500
marked_total = 0
for i in range(0, len(article_ids), BATCH):
chunk = article_ids[i : i + BATCH]
rows = [{"user_id": user.id, "article_id": aid} for aid in chunk]
stmt = pg_insert(ArticleRead).values(rows).on_conflict_do_nothing(
index_elements=["user_id", "article_id"]
)
# PG ON CONFLICT 不直接告诉哪些是"真插入的",通过 result() 或者信任总数;
# 这里简单信任 chunk 大小,marked 返回 attempt 数(去重由 PK 保证)
await session.execute(stmt)
marked_total += len(chunk)
await session.commit()
return CategoryReadResponse(
category=cat,
matched=len(article_ids),
marked=marked_total,
article_ids=article_ids,
)
@router.get("/reads/category-count", response_model=list[CategoryCountItem])
async def count_unread_by_categories(
categories: str = Query(..., description="逗号分隔的分类列表,例如 '美国,社会'"),
window_hours: int = Query(default=24, ge=1, le=168),
sources: str | None = Query(default=None, description="逗号分隔源 slug"),
q: str | None = Query(default=None, description="关键词过滤"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""批量查多个分类的未读数(给前端提示条用,一次拿全,省 round-trip)。
- categories 必填,逗号分隔
- 每个分类独立 count(单次请求内是 N 次查询,数据量小,够用)
- 返回的 unread_count = 0 的分类前端不展示提示行
"""
cat_list = [c.strip() for c in categories.split(",") if c.strip()]
if not cat_list:
return []
src_list = [s.strip() for s in sources.split(",") if s.strip()] if sources else None
eff_q = q.strip() if q and q.strip() else None
results: list[CategoryCountItem] = []
for cat in cat_list[:10]: # 防御性:一次最多 10 个分类
filters = _build_category_filter(
category=cat,
window_hours=window_hours,
user_id=user.id,
sources=src_list,
q=eff_q,
)
stmt = select(func.count(Article.id))
if src_list:
stmt = stmt.join(Source, Source.id == Article.source_id)
for f in filters:
stmt = stmt.where(f)
n = (await session.execute(stmt)).scalar_one()
if n > 0:
results.append(
CategoryCountItem(category=cat, unread_count=int(n), window_hours=window_hours)
)
return results

View File

@@ -142,6 +142,39 @@ export const readsApi = {
'/me/reads', { params: sinceIso ? { since: sinceIso } : {} } '/me/reads', { params: sinceIso ? { since: sinceIso } : {} }
).then((r) => r.data) ).then((r) => r.data)
}, },
// === 按分类批量已读(Feed 提示条用)===
// 一次返回完整 article_ids 列表,前端用它做乐观滑出动画
markCategory(body: {
category: string
scope?: 'all_unread' | 'filtered_unread'
window_hours?: number
sources?: string[]
q?: string
}) {
return http.post<{
category: string
matched: number
marked: number
article_ids: number[]
}>('/me/reads/by-category', body).then((r) => r.data)
},
// 一次查多个分类的未读数(给提示条文案用)— 后端逗号分隔串接
countByCategories(
categories: string[],
params: { window_hours?: number; sources?: string[]; q?: string } = {},
) {
return http.get<{ category: string; unread_count: number; window_hours: number }[]>(
'/me/reads/category-count',
{
params: {
categories: categories.join(','),
window_hours: params.window_hours ?? 24,
sources: params.sources?.length ? params.sources.join(',') : undefined,
q: params.q || undefined,
},
},
).then((r) => r.data)
},
} }
export const sourcesApi = { export const sourcesApi = {

View File

@@ -3,7 +3,7 @@ import { computed, h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin, NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
NPagination, NAutoComplete, useMessage, NPagination, NAutoComplete, NAlert, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles' import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
import { searchApi, type SearchKeyword } from '@/api/search' import { searchApi, type SearchKeyword } from '@/api/search'
@@ -24,6 +24,20 @@ const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素) // 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(new Set()) 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 无限滚动)=== // === 页码分页(替代原来的 cursor 无限滚动)===
const page = ref(1) const page = ref(1)
const pageSize = ref(50) const pageSize = ref(50)
@@ -164,6 +178,13 @@ async function toggleRead(a: ArticleListItem) {
}, 360) }, 360)
} }
} }
// === 新增:刚标为已读 → 查该文章前 2 个 category 的 24h 未读数 ===
// unmark 路径不触发(用户反悔了,不该再骚扰);hide_read 模式仍可触发
// (滑出动画只影响当前这一条,提示条展示的是"这个分类下还有别的未读")
if (!wasRead && a.category) {
await maybePromptCategoryRead(a)
}
} catch (e: any) { } catch (e: any) {
// 失败回滚 // 失败回滚
a.is_read = wasRead 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() { async function loadSources() {
sources.value = await sourcesApi.list() sources.value = await sourcesApi.list()
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug })) sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
@@ -187,6 +296,14 @@ function onPageChange(p: number) {
function resetToFirstPage() { function resetToFirstPage() {
page.value = 1 page.value = 1
load() load()
// 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉
if (categoryPrompts.value.length > 0) {
categoryPrompts.value = []
if (categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = null
}
}
} }
function open(a: ArticleListItem) { function open(a: ArticleListItem) {
@@ -310,6 +427,48 @@ onMounted(async () => {
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText> <NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
</NSpace> </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"> <NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" /> <NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" /> <NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
@@ -884,4 +1043,44 @@ onMounted(async () => {
.card-move { .card-move {
transition: transform 0.35s ease; 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> </style>