feat: add pull gestures for adjacent months

This commit is contained in:
openclaw 2026-07-25 01:32:52 +08:00
parent 0c794ff718
commit ce877719d3
2 changed files with 103 additions and 1 deletions

View File

@ -780,6 +780,11 @@ input:focus-visible {
.ledger-notice-slot { position:absolute; top:0; right:0; left:0; z-index:4; height:0; overflow:visible; pointer-events:none; }
.conversion-notice { height:32px; display:flex; align-items:center; border-bottom:1px solid #eadfca; padding:4px 12px; background:#fff9ed; color:#775b24; font-size:11px; box-shadow:0 2px 8px rgba(58,43,17,.08); pointer-events:auto; }
.ledger-pull-indicator { position:absolute; right:16px; left:16px; z-index:3; display:flex; align-items:center; justify-content:center; gap:5px; height:32px; border:1px solid #d4e3df; border-radius:8px; padding:0 10px; background:rgba(255,255,255,.96); color:#6d807b; box-shadow:0 4px 12px rgba(27,61,53,.1); font-size:11px; pointer-events:none; transform:translateY(calc((var(--pull-progress) - 1) * -20px)); }
.ledger-pull-indicator.top { top:8px; }
.ledger-pull-indicator.bottom { bottom:8px; }
.ledger-pull-indicator.refresh,.ledger-pull-indicator.previous { color:#087f72; }
.ledger-pull-indicator.next { color:#3478e5; }
.conversion-notice span { display:flex; align-items:center; gap:6px; }
.amount-value {

View File

@ -5,6 +5,7 @@ import {
ChevronLeft,
ChevronDown,
ChevronRight,
ChevronUp,
CircleUserRound,
CloudOff,
Copy,
@ -44,6 +45,10 @@ const selectedMonth = ref(toMonthValue(new Date()));
const monthSwipeStart = ref<{ x: number; y: number } | null>(null);
const monthSwipeDelta = ref(0);
const monthSwipeDragging = ref(false);
const pullStartY = ref<number | null>(null);
const pullDistance = ref(0);
const pullEdge = ref<"top" | "bottom" | null>(null);
const pullLoading = ref(false);
let loadMoreObserver: IntersectionObserver | null = null;
const selectedMonthParts = computed(() => {
@ -144,6 +149,26 @@ watch([ledgerContent, loadMoreTrigger], ([root, trigger]) => {
onBeforeUnmount(() => loadMoreObserver?.disconnect());
const pullAction = computed(() => {
if (pullEdge.value === "bottom") return pullDistance.value >= 72 ? "next" : null;
if (pullEdge.value !== "top") return null;
if (pullDistance.value >= 112) return "previous";
if (pullDistance.value >= 52) return "refresh";
return null;
});
const pullLabel = computed(() => {
if (pullAction.value === "previous") return "松开以加载上一月流水";
if (pullAction.value === "refresh") return "松开以刷新流水";
if (pullEdge.value === "bottom") return "继续上拉加载下一月流水";
return "继续下拉刷新流水";
});
const pullProgress = computed(() => {
const threshold = pullEdge.value === "bottom" ? 72 : pullDistance.value < 52 ? 52 : 112;
return Math.min(1, pullDistance.value / threshold);
});
function localDateKey(value: string) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
@ -207,6 +232,59 @@ function cancelMonthSwipe() {
monthSwipeDragging.value = false;
}
function startLedgerPull(event: TouchEvent) {
if (pullLoading.value) return;
const content = ledgerContent.value;
const touch = event.touches[0];
if (!content || !touch) return;
const atTop = content.scrollTop <= 0;
const atBottom = content.scrollHeight > content.clientHeight + 4
&& content.scrollTop + content.clientHeight >= content.scrollHeight - 2;
pullEdge.value = atTop ? "top" : atBottom ? "bottom" : null;
pullStartY.value = pullEdge.value ? touch.clientY : null;
pullDistance.value = 0;
}
function moveLedgerPull(event: TouchEvent) {
const startY = pullStartY.value;
const edge = pullEdge.value;
const touch = event.touches[0];
const content = ledgerContent.value;
if (startY === null || !edge || !touch || !content) return;
const delta = edge === "top" ? touch.clientY - startY : startY - touch.clientY;
if (delta <= 0) {
pullDistance.value = 0;
return;
}
pullDistance.value = Math.min(144, delta * 0.55);
}
async function finishLedgerPull() {
const action = pullAction.value;
pullStartY.value = null;
if (!action || pullLoading.value) {
pullEdge.value = null;
pullDistance.value = 0;
return;
}
pullLoading.value = true;
try {
if (action === "refresh" || action === "previous" || action === "next") await store.syncEntries();
if (action === "previous") shiftMonth(-1);
if (action === "next") shiftMonth(1);
} finally {
pullLoading.value = false;
pullEdge.value = null;
pullDistance.value = 0;
}
}
function cancelLedgerPull() {
pullStartY.value = null;
pullEdge.value = null;
pullDistance.value = 0;
}
function shiftMonth(offset: number) {
const selected = selectedMonthParts.value;
if (!selected) return;
@ -374,7 +452,26 @@ async function shareLedger() {
</section>
</header>
<section ref="ledgerContent" class="ledger-content" aria-label="账本流水">
<section
ref="ledgerContent"
class="ledger-content"
aria-label="账本流水"
@touchstart.passive="startLedgerPull"
@touchmove.passive="moveLedgerPull"
@touchend="finishLedgerPull"
@touchcancel="cancelLedgerPull"
>
<div
v-if="pullEdge && pullDistance > 8"
class="ledger-pull-indicator"
:class="[pullEdge, pullAction, { loading: pullLoading }]"
:style="{ '--pull-progress': pullProgress }"
role="status"
>
<ChevronUp v-if="pullEdge === 'bottom'" :size="15" />
<RefreshCw v-else :size="15" :class="{ spinning: pullLoading }" />
<span>{{ pullLoading ? "正在加载" : pullLabel }}</span>
</div>
<div class="ledger-notice-slot" aria-live="polite">
<div v-if="pendingEntryCount" class="sync-notice" role="status">
<span><CloudOff :size="16" />{{ pendingEntryCount }} 条账目未同步云端</span>