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

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