diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1cee931..8705f33 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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 { diff --git a/apps/web/src/views/LedgerView.vue b/apps/web/src/views/LedgerView.vue index ef77b7d..ef72aee 100644 --- a/apps/web/src/views/LedgerView.vue +++ b/apps/web/src/views/LedgerView.vue @@ -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(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() { -
+
+
+ + + {{ pullLoading ? "正在加载" : pullLabel }} +
{{ pendingEntryCount }} 条账目未同步云端