feat: add ledger month picker modal
This commit is contained in:
parent
18f8d0c36d
commit
81590b2d80
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue