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:
54
backend/alembic/versions/0007_article_reads.py
Normal file
54
backend/alembic/versions/0007_article_reads.py
Normal 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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
46
backend/app/models/article_read.py
Normal file
46
backend/app/models/article_read.py
Normal 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}>"
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user