fix(fe): mark-read btn 到 Feed 卡片右下角新样式 + hide_read 模式卡片滑出动画

This commit is contained in:
xiaji
2026-06-14 09:35:57 +08:00
parent 7de018676a
commit f690f1f108
2 changed files with 148 additions and 26 deletions

View File

@@ -19,6 +19,8 @@ const message = useMessage()
const items = ref<ArticleListItem[]>([])
const sources = ref<Source[]>([])
const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(new Set())
// === 页码分页(替代原来的 cursor 无限滚动)===
const page = ref(1)
@@ -52,7 +54,13 @@ async function load() {
}
}
// === 已读操作(乐观更新,失败回滚)===
// === 已读操作(乐观更新,失败回滚;hide_read 模式下用滑出动画)===
// leave 动画起点(测出卡片真实高度,写到 style 让 max-height 能 transition 到 0)
function beforeCardLeave(el: Element) {
const h = (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.maxHeight = h + 'px'
}
async function toggleRead(a: ArticleListItem) {
const wasRead = a.is_read
a.is_read = !wasRead // 乐观更新
@@ -64,11 +72,19 @@ async function toggleRead(a: ArticleListItem) {
}
// 标记为已读后,如果当前在 hide_read 模式,卡片要从列表里消失
if (!wasRead && hideRead.value) {
// 当前在第 1 页:直接从 items 数组里移除,等下次 load 再精确化
// 等 leave 动画跑完再从 items 数组里移除(TransitionGroup 才能触发动画)
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
if (idx >= 0) {
// 触发 leave 动画:Vue 会保留 DOM 元素直到 transition 结束
// 但 splice(items, idx, 1) 会立即从 v-for 移除 → 用 markPending 标记 → 350ms 后再真正移除
pendingRemoval.value.add(a.id)
setTimeout(() => {
const i = items.value.findIndex((x) => x.id === a.id)
if (i >= 0) items.value.splice(i, 1)
pendingRemoval.value.delete(a.id)
if (total.value > 0) total.value -= 1
}, 360)
}
}
} catch (e: any) {
// 失败回滚
@@ -182,7 +198,13 @@ onMounted(async () => {
<NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
<div v-else>
<TransitionGroup
v-else
name="card"
tag="div"
class="feed-list"
@before-leave="beforeCardLeave"
>
<NCard
v-for="a in items"
:key="a.id"
@@ -357,33 +379,40 @@ onMounted(async () => {
</div>
</div>
<!-- 底部操作栏:已读/未读切换 -->
<NSpace align="center" :size="6" class="feed-actions" @click.stop>
<!-- 底部操作栏:已读/未读切换(浮在卡片右下角) -->
<div class="feed-actions" @click.stop>
<NButton
size="tiny"
:type="a.is_read ? 'default' : 'primary'"
size="small"
:type="a.is_read ? 'tertiary' : 'primary'"
:ghost="!a.is_read"
round
class="feed-read-btn"
:class="{ 'feed-read-btn-read': a.is_read }"
@click.stop="toggleRead(a)"
>
{{ a.is_read ? '✓ 已读(点击标为未读)' : '○ 标为已读' }}
<template #icon>
<span class="feed-read-icon" :class="{ 'feed-read-icon-checked': a.is_read }">
{{ a.is_read ? '✓' : '○' }}
</span>
</template>
{{ a.is_read ? '已读' : '标为已读' }}
</NButton>
</NSpace>
</div>
</NSpace>
</NCard>
</TransitionGroup>
<!-- 页码分页 -->
<NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px">
<NPagination
v-model:page="page"
:page-count="totalPages"
:page-size="pageSize"
show-quick-jumper
@update:page="onPageChange"
/>
</NSpace>
<NText v-else :depth="3" style="display:block; text-align:center; padding: 16px"> 暂无数据 </NText>
</div>
<!-- 页码分页 -->
<NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px">
<NPagination
v-model:page="page"
:page-count="totalPages"
:page-size="pageSize"
show-quick-jumper
@update:page="onPageChange"
/>
</NSpace>
<NText v-else :depth="3" style="display:block; text-align:center; padding: 16px"> 暂无数据 </NText>
</NSpin>
</NSpace>
</template>
@@ -561,16 +590,105 @@ onMounted(async () => {
color: var(--color-text-faint);
}
/* === 底部操作栏 === */
/* === 底部操作栏(浮在卡片右下角)=== */
.feed-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--color-primary-soft);
}
.feed-read-btn {
font-size: 12px;
transition: all 0.2s ease;
}
.feed-read-btn:not(.feed-read-btn-read) {
background: linear-gradient(135deg, var(--color-primary) 0%, #4f7fd1 100%);
color: white;
box-shadow: 0 2px 6px rgba(91, 134, 229, 0.25);
}
.feed-read-btn:not(.feed-read-btn-read):hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(91, 134, 229, 0.35);
}
.feed-read-btn-read {
opacity: 0.7;
}
.feed-read-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
margin-right: 2px;
}
.feed-read-icon-checked {
color: #16a34a;
}
.feed-read-tag {
font-size: 11px;
}
.feed-hideread-toggle {
margin-left: 4px;
}
/* === Feed 列表容器(给 TransitionGroup 用)== */
.feed-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* === 卡片进入/离开动画(标为已读后 hide_read 模式下滑出)== */
.card-enter-active,
.card-leave-active {
transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
max-height 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
margin 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
padding 0.35s cubic-bezier(0.55, 0, 0.55, 0.2);
}
.card-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.card-leave-from {
/* 由 JS 在 before-leave 钩子注入 maxHeight 起点 */
opacity: 1;
transform: translateX(0) scale(1);
}
.card-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.96);
max-height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
border-width: 0 !important;
overflow: hidden;
}
.card-move {
transition: transform 0.35s ease;
}
.card-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.card-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.96);
max-height: 0;
margin: 0;
padding: 0;
border-width: 0;
overflow: hidden;
}
.card-leave-active {
/* max-height transition, 配合 transform 一起 */
max-height: 800px;
}
.card-move {
transition: transform 0.35s ease;
}
</style>