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); 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; min-width: 0;
display: grid; display: grid;
gap: 3px; gap: 3px;

View File

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