diff --git a/.gitignore b/.gitignore index afd415d..17310e9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ node_modules/ .pnpm-store/ *.log .npmrc.local +# TypeScript / Vite 编译产物(vue-tsc build 期间生成,无意义入库) +*.tsbuildinfo +frontend/vite.config.d.ts +frontend/vite.config.js # 编辑器 .idea/ diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index a223120..88380d8 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -19,6 +19,8 @@ const message = useMessage() const items = ref([]) const sources = ref([]) const loading = ref(false) +// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素) +const pendingRemoval = ref>(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 () => { -
+ {
- - + +
- {{ a.is_read ? '✓ 已读(点击标为未读)' : '○ 标为已读' }} + + {{ a.is_read ? '已读' : '标为已读' }} - +
+ - - - - - — 暂无数据 — - + + + + + — 暂无数据 —
@@ -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; +} \ No newline at end of file