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

4
.gitignore vendored
View File

@@ -23,6 +23,10 @@ node_modules/
.pnpm-store/ .pnpm-store/
*.log *.log
.npmrc.local .npmrc.local
# TypeScript / Vite 编译产物(vue-tsc build 期间生成,无意义入库)
*.tsbuildinfo
frontend/vite.config.d.ts
frontend/vite.config.js
# 编辑器 # 编辑器
.idea/ .idea/

View File

@@ -19,6 +19,8 @@ const message = useMessage()
const items = ref<ArticleListItem[]>([]) const items = ref<ArticleListItem[]>([])
const sources = ref<Source[]>([]) const sources = ref<Source[]>([])
const loading = ref(false) const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(new Set())
// === 页码分页(替代原来的 cursor 无限滚动)=== // === 页码分页(替代原来的 cursor 无限滚动)===
const page = ref(1) 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) { async function toggleRead(a: ArticleListItem) {
const wasRead = a.is_read const wasRead = a.is_read
a.is_read = !wasRead // 乐观更新 a.is_read = !wasRead // 乐观更新
@@ -64,11 +72,19 @@ async function toggleRead(a: ArticleListItem) {
} }
// 标记为已读后,如果当前在 hide_read 模式,卡片要从列表里消失 // 标记为已读后,如果当前在 hide_read 模式,卡片要从列表里消失
if (!wasRead && hideRead.value) { if (!wasRead && hideRead.value) {
// 当前在第 1 页:直接从 items 数组里移除,等下次 load 再精确化 // 等 leave 动画跑完再从 items 数组里移除(TransitionGroup 才能触发动画)
const idx = items.value.findIndex((x) => x.id === a.id) const idx = items.value.findIndex((x) => x.id === a.id)
if (idx >= 0) items.value.splice(idx, 1) if (idx >= 0) {
// total 减 1 // 触发 leave 动画:Vue 会保留 DOM 元素直到 transition 结束
if (total.value > 0) total.value -= 1 // 但 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) { } catch (e: any) {
// 失败回滚 // 失败回滚
@@ -182,7 +198,13 @@ onMounted(async () => {
<NSpin :show="loading && items.length === 0"> <NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" /> <NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" /> <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 <NCard
v-for="a in items" v-for="a in items"
:key="a.id" :key="a.id"
@@ -357,33 +379,40 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- 底部操作栏:已读/未读切换 --> <!-- 底部操作栏:已读/未读切换(浮在卡片右下角) -->
<NSpace align="center" :size="6" class="feed-actions" @click.stop> <div class="feed-actions" @click.stop>
<NButton <NButton
size="tiny" size="small"
:type="a.is_read ? 'default' : 'primary'" :type="a.is_read ? 'tertiary' : 'primary'"
:ghost="!a.is_read" :ghost="!a.is_read"
round round
class="feed-read-btn"
:class="{ 'feed-read-btn-read': a.is_read }"
@click.stop="toggleRead(a)" @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> </NButton>
</NSpace> </div>
</NSpace> </NSpace>
</NCard> </NCard>
</TransitionGroup>
<!-- 页码分页 --> <!-- 页码分页 -->
<NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px"> <NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px">
<NPagination <NPagination
v-model:page="page" v-model:page="page"
:page-count="totalPages" :page-count="totalPages"
:page-size="pageSize" :page-size="pageSize"
show-quick-jumper show-quick-jumper
@update:page="onPageChange" @update:page="onPageChange"
/> />
</NSpace> </NSpace>
<NText v-else :depth="3" style="display:block; text-align:center; padding: 16px"> 暂无数据 </NText> <NText v-else :depth="3" style="display:block; text-align:center; padding: 16px"> 暂无数据 </NText>
</div>
</NSpin> </NSpin>
</NSpace> </NSpace>
</template> </template>
@@ -561,16 +590,105 @@ onMounted(async () => {
color: var(--color-text-faint); color: var(--color-text-faint);
} }
/* === 底部操作栏 === */ /* === 底部操作栏(浮在卡片右下角)=== */
.feed-actions { .feed-actions {
margin-top: 8px; display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 10px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--color-primary-soft); 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 { .feed-read-tag {
font-size: 11px; font-size: 11px;
} }
.feed-hideread-toggle { .feed-hideread-toggle {
margin-left: 4px; 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> </style>