feat: animate month swipe feedback

This commit is contained in:
openclaw 2026-07-25 01:23:44 +08:00
parent 44d6f37290
commit b401dc808d
2 changed files with 81 additions and 19 deletions

View File

@ -327,7 +327,20 @@ input:focus-visible {
box-shadow: 0 2px 6px rgba(32, 57, 51, 0.04);
}
.list-toolbar > .list-toolbar-copy {
.list-toolbar-content {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
transform: translateX(var(--month-swipe-offset, 0px));
transition: transform 220ms cubic-bezier(.2,.8,.2,1);
}
.list-toolbar-content.dragging { transition: none; }
.month-swipe-hint { position:absolute; top:50%; left:50%; display:flex; align-items:center; gap:3px; transform:translate(-50%,-50%); border:1px solid #cfe0db; border-radius:999px; padding:4px 8px; background:rgba(255,255,255,.96); color:#087f72; box-shadow:0 4px 12px rgba(26,60,52,.12); font-size:11px; font-weight:700; pointer-events:none; }
.month-swipe-hint.next { color:#3478e5; }
.list-toolbar-copy {
min-width: 0;
display: grid;
gap: 3px;

View File

@ -2,7 +2,9 @@
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
import {
BookOpen,
ChevronLeft,
ChevronDown,
ChevronRight,
CircleUserRound,
CloudOff,
Copy,
@ -40,6 +42,8 @@ const loadMoreTrigger = ref<HTMLElement | null>(null);
const monthInput = ref<HTMLInputElement | null>(null);
const selectedMonth = ref(toMonthValue(new Date()));
const monthSwipeStart = ref<{ x: number; y: number } | null>(null);
const monthSwipeDelta = ref(0);
const monthSwipeDragging = ref(false);
let loadMoreObserver: IntersectionObserver | null = null;
const selectedMonthParts = computed(() => {
@ -158,7 +162,26 @@ function openMonthPicker() {
function startMonthSwipe(event: TouchEvent) {
const touch = event.touches[0];
if (touch) monthSwipeStart.value = { x: touch.clientX, y: touch.clientY };
if (touch) {
monthSwipeStart.value = { x: touch.clientX, y: touch.clientY };
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
}
function moveMonthSwipe(event: TouchEvent) {
const start = monthSwipeStart.value;
const touch = event.touches[0];
if (!start || !touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > 8) {
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
return;
}
monthSwipeDelta.value = Math.max(-96, Math.min(96, deltaX));
monthSwipeDragging.value = Math.abs(deltaX) > 8;
}
function finishMonthSwipe(event: TouchEvent) {
@ -168,8 +191,20 @@ function finishMonthSwipe(event: TouchEvent) {
if (!start || !touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY)) {
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
return;
}
shiftMonth(deltaX < 0 ? 1 : -1);
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
function cancelMonthSwipe() {
monthSwipeStart.value = null;
monthSwipeDelta.value = 0;
monthSwipeDragging.value = false;
}
function shiftMonth(offset: number) {
@ -178,6 +213,13 @@ function shiftMonth(offset: number) {
selectedMonth.value = toMonthValue(new Date(selected.year, selected.month + offset, 1));
}
const monthSwipePreview = computed(() => {
const selected = selectedMonthParts.value;
if (!selected || !monthSwipeDelta.value) return "";
const date = new Date(selected.year, selected.month + (monthSwipeDelta.value < 0 ? 1 : -1), 1);
return `${date.getFullYear()}${date.getMonth() + 1}`;
});
function dateHeading(value: string) {
const date = new Date(`${value}T00:00:00`);
const today = new Date();
@ -344,24 +386,31 @@ async function shareLedger() {
<span><Clock3 :size="15" />{{ pendingConversionCount }} 条外币账目等待换算</span>
</div>
</div>
<div class="list-toolbar" @touchstart.passive="startMonthSwipe" @touchend="finishMonthSwipe">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
<button class="list-month-switcher" type="button" @click="openMonthPicker">
<strong>{{ monthTitle }}</strong>
<ChevronDown :size="14" />
</button>
<input ref="monthInput" v-model="selectedMonth" class="native-month-input" type="month" aria-label="选择流水月份" tabindex="-1" />
<span>{{ monthlyEntries.length }} </span>
</div>
<div class="list-toolbar-totals">
<span class="income">收入 {{ formatMoney(totals.income) }}</span>
<span class="expense">支出 {{ formatMoney(totals.expense) }}</span>
<div class="list-toolbar" @touchstart.passive="startMonthSwipe" @touchmove.passive="moveMonthSwipe" @touchend="finishMonthSwipe" @touchcancel="cancelMonthSwipe">
<div class="list-toolbar-content" :class="{ dragging: monthSwipeDragging }" :style="{ '--month-swipe-offset': `${monthSwipeDelta * 0.22}px` }">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
<button class="list-month-switcher" type="button" @click="openMonthPicker">
<strong>{{ monthTitle }}</strong>
<ChevronDown :size="14" />
</button>
<input ref="monthInput" v-model="selectedMonth" class="native-month-input" type="month" aria-label="选择流水月份" tabindex="-1" />
<span>{{ monthlyEntries.length }} </span>
</div>
<div class="list-toolbar-totals">
<span class="income">收入 {{ formatMoney(totals.income) }}</span>
<span class="expense">支出 {{ formatMoney(totals.expense) }}</span>
</div>
</div>
<button class="icon-button list-action" type="button" aria-label="搜索流水" title="搜索流水">
<Search :size="19" />
</button>
</div>
<div v-if="monthSwipeDragging" class="month-swipe-hint" :class="{ next: monthSwipeDelta < 0 }" aria-hidden="true">
<ChevronRight v-if="monthSwipeDelta < 0" :size="14" />
<ChevronLeft v-else :size="14" />
<span>{{ monthSwipePreview }}</span>
</div>
<button class="icon-button list-action" type="button" aria-label="搜索流水" title="搜索流水">
<Search :size="19" />
</button>
</div>
<section v-for="group in groupedEntries" :key="group.date" class="day-group">