fix(fe): mark-read btn 到 Feed 卡片右下角新样式 + hide_read 模式卡片滑出动画
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user