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