diff --git a/backend/app/api/me.py b/backend/app/api/me.py index e9988fc..2978e37 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -1,11 +1,13 @@ -"""/me 当前用户信息 + 翻译配额 + 已读文章。""" +"""/me 当前用户信息 + 翻译配额 + 已读文章 + 按分类批量已读。""" from __future__ import annotations from datetime import datetime, timedelta, timezone +from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel -from sqlalchemy import and_, delete, select +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +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 app.config import settings @@ -13,6 +15,7 @@ from app.core.deps import get_current_user from app.database import get_session from app.models.article import Article from app.models.article_read import ArticleRead +from app.models.source import Source from app.models.user import User from app.redis_client import get_redis @@ -95,7 +98,6 @@ async def mark_read( if not exists: raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found") # 用 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( index_elements=["user_id", "article_id"] ) @@ -142,3 +144,208 @@ async def list_reads( rows = (await session.execute(stmt)).all() ids = [r[0] for r in rows] 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 diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 4a53555..2cf2db8 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -142,6 +142,39 @@ export const readsApi = { '/me/reads', { params: sinceIso ? { since: sinceIso } : {} } ).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 = { diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index 9b64c8b..f25aed5 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -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>(new Set()) +// === 分类批量已读提示条 === +// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数"; +// 大于 0 的 category 各显示一行提示,带独立"全部已读 / 稍后再说"按钮 +// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出 +// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 重置或清空) +type CategoryPromptItem = { + category: string + unreadCount: number + triggeredById: number +} +const categoryPrompts = ref([]) +const pendingCategory = ref(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 () => { {{ itemsLabel }} + + +
+ + + + + 全部已读 + + + 稍后再说 + + + +
+
+ @@ -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; +} \ No newline at end of file