diff --git a/backend/alembic/versions/0007_article_reads.py b/backend/alembic/versions/0007_article_reads.py new file mode 100644 index 0000000..3c6080f --- /dev/null +++ b/backend/alembic/versions/0007_article_reads.py @@ -0,0 +1,54 @@ +"""文章已读记录(per-user) + +- article_reads.user_id BIGINT FK users.id ON DELETE CASCADE +- article_reads.article_id BIGINT FK articles.id ON DELETE CASCADE +- article_reads.read_at TIMESTAMPTZ DEFAULT now() +- 复合主键 (user_id, article_id) — 天然幂等 + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-06-13 +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007" +down_revision: Union[str, None] = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "article_reads", + sa.Column("user_id", sa.BigInteger, nullable=False), + sa.Column("article_id", sa.BigInteger, nullable=False), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("user_id", "article_id", name="pk_article_reads"), + sa.ForeignKeyConstraint( + "user_id", ["users.id"], ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + "article_id", ["articles.id"], ondelete="CASCADE", + ), + ) + op.create_index( + "ix_article_reads_user_read_at", + "article_reads", + ["user_id", "read_at"], + ) + op.create_index( + "ix_article_reads_article", + "article_reads", + ["article_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_article_reads_article", table_name="article_reads") + op.drop_index("ix_article_reads_user_read_at", table_name="article_reads") + op.drop_table("article_reads") diff --git a/backend/app/api/articles.py b/backend/app/api/articles.py index cff692a..eec97ab 100644 --- a/backend/app/api/articles.py +++ b/backend/app/api/articles.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession 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.bookmark import Bookmark from app.models.source import Source from app.models.user import User @@ -35,6 +36,7 @@ async def list_articles( page: int = Query(default=1, ge=1, description="页码(从 1 开始)"), page_size: int = Query(default=50, ge=1, le=200, description="每页条数"), starred_only: bool = False, + hide_read: bool = Query(default=True, description="是否隐藏已读文章(默认 true)"), user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): @@ -64,6 +66,18 @@ async def list_articles( if lang == "zh": filters.append(Article.title_zh.is_not(None)) + if hide_read: + # LEFT JOIN article_reads,WHERE 排除已读 + # 注意:这里用 NOT EXISTS 子查询更干净(避免影响 count/select 的 JOIN 链) + from sqlalchemy import not_, exists + filters.append( + not_( + select(ArticleRead.article_id) + .where(ArticleRead.user_id == user.id, ArticleRead.article_id == Article.id) + .exists() + ) + ) + # ===== count 总数 ===== count_stmt = select(func.count(Article.id)).join(Source, Source.id == Article.source_id) for f in filters: @@ -97,9 +111,10 @@ async def list_articles( rows = (await session.execute(stmt)).all() - # 标记 is_starred(批量) + # 标记 is_starred(批量)+ is_read(批量) ids = [a.id for a, _ in rows] starred_ids: set[int] = set() + read_ids: set[int] = set() if ids: bm_rows = ( await session.execute( @@ -109,6 +124,15 @@ async def list_articles( ) ).all() starred_ids = {b[0] for b in bm_rows} + # 查 is_read(无论 hide_read 都查,前端可拿"已读"标识,例如"显示已读"开关打开时仍要知道哪些是已读的) + rd_rows = ( + await session.execute( + select(ArticleRead.article_id).where( + ArticleRead.user_id == user.id, ArticleRead.article_id.in_(ids) + ) + ) + ).all() + read_ids = {r[0] for r in rd_rows} items = [] for art, src in rows: @@ -134,6 +158,7 @@ async def list_articles( commentary_engine=art.commentary_engine, image_ai_url=art.image_ai_url, is_starred=art.id in starred_ids, + is_read=art.id in read_ids, ) ) @@ -170,6 +195,14 @@ async def get_article( ) ).first() is not None + is_read = ( + await session.execute( + select(ArticleRead.article_id).where( + ArticleRead.user_id == user.id, ArticleRead.article_id == article.id + ) + ) + ).first() is not None + return ArticleDetail( id=article.id, source=SourceBrief.model_validate(source), @@ -207,6 +240,7 @@ async def get_article( published_at=article.published_at, fetched_at=article.fetched_at, is_starred=is_starred, + is_read=is_read, ) diff --git a/backend/app/api/me.py b/backend/app/api/me.py index 66a0b71..e9988fc 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -1,15 +1,18 @@ -"""/me 当前用户信息 + 翻译配额。""" +"""/me 当前用户信息 + 翻译配额 + 已读文章。""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings 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.user import User from app.redis_client import get_redis @@ -34,6 +37,17 @@ class UsageOut(BaseModel): pct_used: float +class ReadToggleResponse(BaseModel): + article_id: int + is_read: bool + + +class ReadListResponse(BaseModel): + """已读文章 ID 列表(给前端用,Feed 列表展示).""" + article_ids: list[int] + total: int + + @router.get("", response_model=MeOut) async def me(user: User = Depends(get_current_user)): return MeOut( @@ -66,3 +80,65 @@ async def usage( buffered_quota=buffered, pct_used=round(used / quota * 100, 2) if quota else 0.0, ) + + +# === 已读文章(per-user) === +@router.post("/reads/{article_id}", response_model=ReadToggleResponse) +async def mark_read( + article_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """标记文章为已读(幂等,重复调用不报错)。""" + # 先确认文章存在(不存在返 404,避免 FK 报错) + exists = (await session.execute(select(Article.id).where(Article.id == article_id))).scalar_one_or_none() + 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"] + ) + await session.execute(stmt) + await session.commit() + return ReadToggleResponse(article_id=article_id, is_read=True) + + +@router.delete("/reads/{article_id}", response_model=ReadToggleResponse) +async def unmark_read( + article_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """取消已读(幂等)。""" + stmt = delete(ArticleRead).where( + and_(ArticleRead.user_id == user.id, ArticleRead.article_id == article_id) + ) + res = await session.execute(stmt) + await session.commit() + return ReadToggleResponse(article_id=article_id, is_read=False) + + +@router.get("/reads", response_model=ReadListResponse) +async def list_reads( + since: datetime | None = None, + limit: int = 500, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """列出当前用户已读文章 ID(给 Feed 过滤 / 已读时间线用)。 + + - since: 只返回 read_at >= since 的(默认 7 天前,避免数据爆炸) + - limit: 最多返回多少(默认 500) + """ + if since is None: + since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7) + stmt = ( + select(ArticleRead.article_id) + .where(ArticleRead.user_id == user.id, ArticleRead.read_at >= since) + .order_by(ArticleRead.read_at.desc()) + .limit(min(limit, 2000)) + ) + rows = (await session.execute(stmt)).all() + ids = [r[0] for r in rows] + return ReadListResponse(article_ids=ids, total=len(ids)) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d16d410..fc2d0a8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ """ from app.models.api_token import ApiToken # noqa: F401 from app.models.article import Article # noqa: F401 +from app.models.article_read import ArticleRead # noqa: F401 from app.models.bookmark import Bookmark # noqa: F401 from app.models.llm_setting import LlmSetting # noqa: F401 from app.models.source import Source, SourceKind # noqa: F401 @@ -13,6 +14,7 @@ from app.models.user import User, UserRole # noqa: F401 __all__ = [ "ApiToken", "Article", + "ArticleRead", "Bookmark", "LlmSetting", "Source", diff --git a/backend/app/models/article_read.py b/backend/app/models/article_read.py new file mode 100644 index 0000000..94e0031 --- /dev/null +++ b/backend/app/models/article_read.py @@ -0,0 +1,46 @@ +"""文章已读记录(per-user)。 + +设计: +- 复合主键 (user_id, article_id) — 天然防重复,标记已读是幂等的 +- read_at 默认 now(),用于排序(最近已读在前) +- 索引: (user_id, read_at DESC) — 用户查自己最近已读用 + (article_id) — 反向查"谁读过这文章"(暂不用,留扩展) + +注意: +- 删文章时 ondelete=CASCADE 自动清掉已读记录 +- 这是和 bookmarks 并列的"用户行为"表 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ArticleRead(Base): + __tablename__ = "article_reads" + + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + article_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("articles.id", ondelete="CASCADE"), + primary_key=True, + ) + read_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + __table_args__ = ( + Index("ix_article_reads_user_read_at", "user_id", "read_at"), + Index("ix_article_reads_article", "article_id"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/article.py b/backend/app/schemas/article.py index 5418e0b..19bbaae 100644 --- a/backend/app/schemas/article.py +++ b/backend/app/schemas/article.py @@ -42,6 +42,7 @@ class ArticleListItem(BaseModel): commentary_engine: str | None = None # angel / meituan / "angel,meituan" image_ai_url: str | None = None # AI 插图(列表里缩略图) is_starred: bool = False + is_read: bool = False class ArticleDetail(BaseModel): @@ -84,6 +85,7 @@ class ArticleDetail(BaseModel): published_at: datetime | None = None fetched_at: datetime is_starred: bool = False + is_read: bool = False class ArticleListResponse(BaseModel): diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 670609c..fe169c4 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -42,6 +42,7 @@ export interface ArticleListItem { commentary_engine?: string | null // angel / meituan / "angel,meituan" image_ai_url?: string | null is_starred: boolean + is_read: boolean // 当前用户是否已读 } export interface ArticleListResponse { @@ -78,6 +79,7 @@ export interface ArticleDetail extends ArticleListItem { entities?: Record | null sentiment?: number | null duplicate_of?: number | null + is_read?: boolean } export interface LlmSetting { @@ -118,6 +120,25 @@ export const articlesApi = { }, } +// === 已读文章(per-user) === +export const readsApi = { + mark(articleId: number) { + return http.post<{ article_id: number; is_read: boolean }>( + `/me/reads/${articleId}` + ).then((r) => r.data) + }, + unmark(articleId: number) { + return http.delete<{ article_id: number; is_read: boolean }>( + `/me/reads/${articleId}` + ).then((r) => r.data) + }, + list(sinceIso?: string) { + return http.get<{ article_ids: number[]; total: number }>( + '/me/reads', { params: sinceIso ? { since: sinceIso } : {} } + ).then((r) => r.data) + }, +} + export const sourcesApi = { list() { return http.get('/sources').then((r) => r.data) diff --git a/frontend/src/views/ArticleDetail.vue b/frontend/src/views/ArticleDetail.vue index 728ecf5..cd36835 100644 --- a/frontend/src/views/ArticleDetail.vue +++ b/frontend/src/views/ArticleDetail.vue @@ -55,6 +55,29 @@ async function toggleStar() { } } +// === 已读 toggle === +async function toggleRead() { + if (!article.value) return + const { readsApi } = await import('@/api/articles') + const wasRead = !!article.value.is_read + article.value.is_read = !wasRead // 乐观 + try { + if (wasRead) { + await readsApi.unmark(article.value.id) + message.info('已标为未读') + } else { + await readsApi.mark(article.value.id) + message.success('已标为已读') + } + } catch (e: any) { + article.value.is_read = wasRead + message.error(e?.response?.data?.title || '操作失败') + } + } catch (e: any) { + message.error(e?.response?.data?.title || '操作失败') + } +} + function fmtTime(s?: string | null) { if (!s) return '—' return dayjs(s).format('YYYY-MM-DD HH:mm [UTC]') @@ -233,6 +256,14 @@ onMounted(load) > {{ starred ? '★ 已收藏' : '☆ 收藏' }} + + {{ article.is_read ? '✓ 已读' : '○ 标为已读' }} + {{ showOriginal ? '隐藏原文' : '显示原文' }} diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index d9eb723..30ed9c2 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -27,6 +27,8 @@ const totalPages = ref(1) const sourceFilter = ref([]) const q = ref('') +// 已读过滤:hide_read = true → 默认隐藏已读;切换显示 +const hideRead = ref(true) const sourceOptions = ref<{ label: string; value: string }[]>([]) @@ -39,6 +41,7 @@ async function load() { q: q.value || undefined, page: page.value, page_size: pageSize.value, + hide_read: hideRead.value ? 'true' : 'false', }) items.value = resp.items total.value = resp.total @@ -48,6 +51,31 @@ async function load() { } } +// === 已读操作(乐观更新,失败回滚)=== +async function toggleRead(a: ArticleListItem) { + const wasRead = a.is_read + a.is_read = !wasRead // 乐观更新 + try { + if (wasRead) { + await readsApi.unmark(a.id) + } else { + await readsApi.mark(a.id) + } + // 标记为已读后,如果当前在 hide_read 模式,卡片要从列表里消失 + if (!wasRead && hideRead.value) { + // 当前在第 1 页:直接从 items 数组里移除,等下次 load 再精确化 + const idx = items.value.findIndex((x) => x.id === a.id) + if (idx >= 0) items.value.splice(idx, 1) + // total 减 1 + if (total.value > 0) total.value -= 1 + } + } catch (e: any) { + // 失败回滚 + a.is_read = wasRead + message.error(e?.response?.data?.title || '操作失败') + } +} + async function loadSources() { sources.value = await sourcesApi.list() sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug })) @@ -141,6 +169,10 @@ onMounted(async () => { /> + + 隐藏已读 + + 刷新 {{ itemsLabel }} @@ -154,6 +186,7 @@ onMounted(async () => { v-for="a in items" :key="a.id" class="article-card" + :class="{ 'article-card-read': a.is_read }" hoverable @click="open(a)" > @@ -179,6 +212,17 @@ onMounted(async () => { > {{ c }} + + + ✓ 已读 + {{ fmtTime(a.published_at || a.fetched_at) }} @@ -311,6 +355,19 @@ onMounted(async () => { + + + + + {{ a.is_read ? '✓ 已读(点击标为未读)' : '○ 标为已读' }} + + @@ -490,4 +547,29 @@ onMounted(async () => { text-align: right; } } + +/* === 已读卡片视觉降级 === */ +.article-card-read { + opacity: 0.7; + background: #fafafa; +} +.article-card-read :deep(.n-card-header) { + color: var(--color-text-faint); +} +.article-card-read .commentary-text { + color: var(--color-text-faint); +} + +/* === 底部操作栏 === */ +.feed-actions { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--color-primary-soft); +} +.feed-read-tag { + font-size: 11px; +} +.feed-hideread-toggle { + margin-left: 4px; +} \ No newline at end of file