feat(read): 已读功能 — 每账号标已读,列表默认隐藏

需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。

设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段

前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
    顶部加 '隐藏已读' 开关,默认 ON
    每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
    已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
This commit is contained in:
xiaji
2026-06-13 21:04:47 +08:00
parent 8b3c7caf87
commit 6c71ab2e79
9 changed files with 352 additions and 4 deletions

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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))

View File

@@ -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",

View File

@@ -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"<ArticleRead user={self.user_id} article={self.article_id} at={self.read_at}>"

View File

@@ -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):

View File

@@ -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<string, any> | 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<Source[]>('/sources').then((r) => r.data)

View File

@@ -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 ? '★ 已收藏' : '☆ 收藏' }}
</NButton>
<NButton
:type="article.is_read ? 'default' : 'info'"
:ghost="!article.is_read"
@click="toggleRead"
round
>
{{ article.is_read ? '✓ 已读' : '○ 标为已读' }}
</NButton>
<NButton text @click="showOriginal = !showOriginal" round>
{{ showOriginal ? '隐藏原文' : '显示原文' }}
</NButton>

View File

@@ -27,6 +27,8 @@ const totalPages = ref(1)
const sourceFilter = ref<string[]>([])
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 () => {
/>
<NInput v-model:value="q" placeholder="关键词搜索" clearable class="feed-search-input"
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
<NSpace align="center" :size="6" class="feed-hideread-toggle">
<NText style="font-size: 13px">隐藏已读</NText>
<NSwitch v-model:value="hideRead" @update:value="resetToFirstPage" />
</NSpace>
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
</NSpace>
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
@@ -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 }}
</NTag>
<!-- 已读/未读小标签 -->
<NTag
v-if="a.is_read"
size="tiny"
:bordered="false"
round
type="default"
class="feed-read-tag"
>
已读
</NTag>
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
{{ fmtTime(a.published_at || a.fetched_at) }}
</NText>
@@ -311,6 +355,19 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- 底部操作栏:已读/未读切换 -->
<NSpace align="center" :size="6" class="feed-actions" @click.stop>
<NButton
size="tiny"
:type="a.is_read ? 'default' : 'primary'"
:ghost="!a.is_read"
round
@click.stop="toggleRead(a)"
>
{{ a.is_read ? '✓ 已读(点击标为未读)' : '○ 标为已读' }}
</NButton>
</NSpace>
</NSpace>
</NCard>
@@ -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;
}
</style>