feat: add ledger month picker modal

This commit is contained in:
openclaw 2026-07-25 23:28:22 +08:00
parent 18f8d0c36d
commit 81590b2d80
2 changed files with 131 additions and 6 deletions

View File

@ -409,6 +409,33 @@ input:focus-visible {
pointer-events: none;
}
.month-picker-layer { position:absolute; inset:0; z-index:16; }
.month-picker-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.4); backdrop-filter:blur(3px); }
.month-picker-modal { position:absolute; right:0; bottom:0; left:0; max-height:82%; overflow-y:auto; border-radius:14px 14px 0 0; padding:0 16px calc(18px + env(safe-area-inset-bottom)); background:#fff; color:#22302c; box-shadow:0 -16px 36px rgba(22,45,40,.22); }
.month-picker-modal > header { min-height:56px; display:grid; grid-template-columns:38px 1fr 38px; align-items:center; border-bottom:1px solid #e1ebe8; }
.month-picker-modal > header strong { text-align:center; font-size:16px; }
.month-picker-modal > header button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
.month-picker-year { width:100%; min-height:54px; display:flex; align-items:center; justify-content:center; gap:4px; border:0; background:transparent; color:#26342f; }
.month-picker-year strong { font-size:21px; }
.month-picker-total { display:flex; justify-content:center; gap:18px; margin:-2px 0 14px; font-size:12px; font-variant-numeric:tabular-nums; }
.month-picker-total .income,.month-picker-grid .income,.year-picker-list .income { color:#0d8b67; }
.month-picker-total .expense,.month-picker-grid .expense,.year-picker-list .expense { color:#d84c36; }
.month-picker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.month-picker-grid button { min-height:68px; display:grid; align-content:center; gap:2px; border:1px solid #e1ebe8; border-radius:8px; padding:7px 5px; background:#fff; text-align:center; }
.month-picker-grid button.selected { border-color:#087f72; background:#eaf7f3; box-shadow:inset 0 0 0 1px #087f72; }
.month-picker-grid button strong { color:#26342f; font-size:14px; }
.month-picker-grid button small { overflow:hidden; color:#7a8783; font-size:10px; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; }
.year-picker-list { display:grid; gap:2px; padding-top:8px; }
.year-picker-list button { min-height:58px; display:flex; align-items:center; justify-content:space-between; border:0; border-bottom:1px solid #e5edeb; padding:0 8px; background:#fff; color:#26342f; text-align:left; }
.year-picker-list button.selected { color:#087f72; }
.year-picker-list button strong { font-size:16px; }
.year-picker-list button span { display:flex; gap:10px; font-size:11px; font-variant-numeric:tabular-nums; }
.year-picker-list button i { font-style:normal; }
.month-picker-enter-active,.month-picker-leave-active { transition:opacity 180ms ease; }
.month-picker-enter-active .month-picker-modal,.month-picker-leave-active .month-picker-modal { transition:transform 220ms ease; }
.month-picker-enter-from,.month-picker-leave-to { opacity:0; }
.month-picker-enter-from .month-picker-modal,.month-picker-leave-to .month-picker-modal { transform:translateY(100%); }
.list-toolbar strong {
font-size: 15px;
}

View File

@ -41,8 +41,10 @@ const generatingInvitation = ref(false);
const visibleCount = ref(ENTRY_PAGE_SIZE);
const ledgerContent = ref<HTMLElement | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const monthInput = ref<HTMLInputElement | null>(null);
const selectedMonth = ref(toMonthValue(new Date()));
const monthPickerOpen = ref(false);
const monthPickerMode = ref<"months" | "years">("months");
const monthPickerYear = ref(new Date().getFullYear());
const monthSwipeStart = ref<{ x: number; y: number } | null>(null);
const monthSwipeDelta = ref(0);
const monthSwipeDragging = ref(false);
@ -120,6 +122,44 @@ const groupedEntries = computed(() => {
}));
});
const ledgerEntries = computed(() => store.entries.filter((entry) => entry.ledgerIds.includes(ledgerStore.currentLedgerId)));
function summarizeEntries(entries: LedgerEntry[]) {
return entries.reduce(
(result, entry) => {
if (entry.baseAmount === null) return result;
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
},
{ income: 0, expense: 0 },
);
}
const availableYears = computed(() => {
const years = new Set<number>([new Date().getFullYear(), ...ledgerEntries.value.map((entry) => new Date(entry.occurredAt).getFullYear())]);
return [...years].sort((left, right) => right - left);
});
const yearSummaries = computed(() => availableYears.value.map((year) => ({
year,
...summarizeEntries(ledgerEntries.value.filter((entry) => new Date(entry.occurredAt).getFullYear() === year)),
})));
const monthSummaries = computed(() => Array.from({ length: 12 }, (_, month) => ({
month,
...summarizeEntries(ledgerEntries.value.filter((entry) => {
const date = new Date(entry.occurredAt);
return date.getFullYear() === monthPickerYear.value && date.getMonth() === month;
})),
})));
const pickerYearSummary = computed(() => yearSummaries.value.find((summary) => summary.year === monthPickerYear.value) ?? {
year: monthPickerYear.value,
income: 0,
expense: 0,
});
const monthTitle = computed(() => {
const selected = selectedMonthParts.value;
return selected ? `${selected.year}${selected.month + 1}` : "选择月份";
@ -180,10 +220,23 @@ function toMonthValue(date: Date) {
}
function openMonthPicker() {
const input = monthInput.value;
if (!input) return;
if (typeof input.showPicker === "function") input.showPicker();
else input.click();
monthPickerYear.value = selectedMonthParts.value?.year ?? new Date().getFullYear();
monthPickerMode.value = "months";
monthPickerOpen.value = true;
}
function closeMonthPicker() {
monthPickerOpen.value = false;
}
function selectPickerYear(year: number) {
monthPickerYear.value = year;
monthPickerMode.value = "months";
}
function selectPickerMonth(month: number) {
selectedMonth.value = `${monthPickerYear.value}-${String(month + 1).padStart(2, "0")}`;
closeMonthPicker();
}
function startMonthSwipe(event: TouchEvent) {
@ -336,6 +389,13 @@ function formatMoney(value: number) {
});
}
function formatCompactMoney(value: number) {
const amount = value / 100;
if (amount >= 10000) return `${(amount / 10000).toFixed(1)}`;
if (amount >= 1000) return `${(amount / 1000).toFixed(1)}k`;
return amount.toLocaleString("zh-CN", { maximumFractionDigits: 0 });
}
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
@ -451,6 +511,45 @@ async function shareLedger() {
</div>
</Transition>
<Transition name="month-picker">
<div v-if="monthPickerOpen" class="month-picker-layer">
<button class="month-picker-scrim" type="button" aria-label="关闭月份选择" @click="closeMonthPicker"></button>
<section class="month-picker-modal" role="dialog" aria-modal="true" aria-label="选择月份">
<header>
<button v-if="monthPickerMode === 'years'" type="button" aria-label="返回月份选择" title="返回月份选择" @click="monthPickerMode = 'months'"><ChevronLeft :size="20" /></button>
<span v-else></span>
<strong>{{ monthPickerMode === "months" ? "选择月份" : "选择年份" }}</strong>
<button type="button" aria-label="关闭" title="关闭" @click="closeMonthPicker"><X :size="20" /></button>
</header>
<template v-if="monthPickerMode === 'months'">
<button class="month-picker-year" type="button" @click="monthPickerMode = 'years'">
<strong>{{ monthPickerYear }}</strong>
<ChevronDown :size="16" />
</button>
<div class="month-picker-total">
<span class="income">+ ¥{{ formatMoney(pickerYearSummary.income) }}</span>
<span class="expense"> ¥{{ formatMoney(pickerYearSummary.expense) }}</span>
</div>
<div class="month-picker-grid">
<button v-for="summary in monthSummaries" :key="summary.month" type="button" :class="{ selected: selectedMonth === `${monthPickerYear}-${String(summary.month + 1).padStart(2, '0')}` }" @click="selectPickerMonth(summary.month)">
<strong>{{ summary.month + 1 }}</strong>
<small class="income">+{{ formatCompactMoney(summary.income) }}</small>
<small class="expense">{{ formatCompactMoney(summary.expense) }}</small>
</button>
</div>
</template>
<div v-else class="year-picker-list">
<button v-for="summary in yearSummaries" :key="summary.year" type="button" :class="{ selected: summary.year === monthPickerYear }" @click="selectPickerYear(summary.year)">
<strong>{{ summary.year }}</strong>
<span><i class="income">+{{ formatCompactMoney(summary.income) }}</i><i class="expense">{{ formatCompactMoney(summary.expense) }}</i></span>
</button>
</div>
</section>
</div>
</Transition>
<section class="monthly-summary" aria-label="月度收支">
<div class="summary-main">
<span>结余</span>
@ -497,7 +596,6 @@ async function shareLedger() {
<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">