fix: keep stats range separate from chart granularity
This commit is contained in:
parent
a196a04668
commit
52c6563ac7
|
|
@ -175,6 +175,13 @@ const entries = computed(() => {
|
|||
const totals = computed(() => summarize(entries.value));
|
||||
const convertedEntries = computed(() => entries.value.filter((entry) => entry.baseAmount !== null));
|
||||
const pendingConversionCount = computed(() => entries.value.length - convertedEntries.value.length);
|
||||
const calendarCursorMonth = ref(selectedMonth.value);
|
||||
|
||||
watch([rangeMode, selectedMonth, selectedYear, customStart, customEnd], () => {
|
||||
const bounds = rangeBounds.value;
|
||||
if (!bounds) return;
|
||||
calendarCursorMonth.value = `${bounds.start.getFullYear()}-${String(bounds.start.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
const dailyStats = computed(() => {
|
||||
const groups = new Map<string, LedgerEntry[]>();
|
||||
|
|
@ -208,7 +215,12 @@ const flowStats = computed(() => {
|
|||
|
||||
const calendarMonthStart = computed(() => {
|
||||
const bounds = rangeBounds.value;
|
||||
return bounds ? new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1) : null;
|
||||
if (!bounds) return null;
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(calendarCursorMonth.value);
|
||||
const candidate = match ? new Date(Number(match[1]), Number(match[2]) - 1, 1) : bounds.start;
|
||||
const first = new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1);
|
||||
const last = new Date(bounds.end.getFullYear(), bounds.end.getMonth(), 1);
|
||||
return candidate < first ? first : candidate >= last ? new Date(last.getFullYear(), last.getMonth() - 1, 1) : candidate;
|
||||
});
|
||||
|
||||
const calendarMonthLabel = computed(() => {
|
||||
|
|
@ -230,6 +242,35 @@ const calendarDays = computed(() => {
|
|||
return cells;
|
||||
});
|
||||
|
||||
const calendarMonthCells = computed(() => {
|
||||
const bounds = rangeBounds.value;
|
||||
if (!bounds) return [];
|
||||
const summary = new Map(flowStats.value.map((item) => [item.date, item]));
|
||||
const cells: Array<{ date: string; label: string; income: number; expense: number }> = [];
|
||||
const cursor = new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1);
|
||||
const end = new Date(bounds.end.getFullYear(), bounds.end.getMonth(), 1);
|
||||
while (cursor < end) {
|
||||
const date = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`;
|
||||
const item = summary.get(date);
|
||||
cells.push({ date, label: `${cursor.getFullYear()}年${cursor.getMonth() + 1}月`, income: item?.income ?? 0, expense: item?.expense ?? 0 });
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
const calendarYearCells = computed(() => {
|
||||
const bounds = rangeBounds.value;
|
||||
if (!bounds) return [];
|
||||
const summary = new Map(flowStats.value.map((item) => [item.date, item]));
|
||||
const cells: Array<{ date: string; label: string; income: number; expense: number }> = [];
|
||||
for (let year = bounds.start.getFullYear(); year <= new Date(bounds.end.getTime() - 1).getFullYear(); year += 1) {
|
||||
const date = String(year);
|
||||
const item = summary.get(date);
|
||||
cells.push({ date, label: `${year}年`, income: item?.income ?? 0, expense: item?.expense ?? 0 });
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
const lineStats = computed(() => [...flowStats.value].reverse());
|
||||
const lineMaxAmount = computed(() => Math.max(1, ...lineStats.value.flatMap((day) => [day.income, day.expense])));
|
||||
|
||||
|
|
@ -466,11 +507,15 @@ function shiftRangePickerYear(offset: -1 | 1) {
|
|||
}
|
||||
|
||||
function shiftCalendarMonth(offset: -1 | 1) {
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(calendarCursorMonth.value);
|
||||
if (!match) return;
|
||||
const next = new Date(Number(match[1]), Number(match[2]) - 1 + offset, 1);
|
||||
selectedMonth.value = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
|
||||
rangeMode.value = "month";
|
||||
const bounds = rangeBounds.value;
|
||||
if (!bounds) return;
|
||||
const first = new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1);
|
||||
const last = new Date(bounds.end.getFullYear(), bounds.end.getMonth(), 1);
|
||||
if (next < first || next >= last) return;
|
||||
calendarCursorMonth.value = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -560,11 +605,18 @@ function shiftCalendarMonth(offset: -1 | 1) {
|
|||
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
|
||||
<header><strong>收支趋势</strong><div class="daily-controls"><div class="daily-granularity-switch" role="group" aria-label="统计粒度"><button type="button" :class="{ active: flowGranularity === 'day' }" @click="flowGranularity = 'day'">日</button><button type="button" :class="{ active: flowGranularity === 'month' }" @click="flowGranularity = 'month'">月</button><button type="button" :class="{ active: flowGranularity === 'year' }" @click="flowGranularity = 'year'">年</button></div><div class="daily-view-switch" role="group" aria-label="收支视图"><button type="button" :class="{ active: dailyView === 'calendar' }" aria-label="日历视图" title="日历视图" @click="dailyView = 'calendar'"><CalendarDays :size="15" /></button><button type="button" :class="{ active: dailyView === 'line' }" aria-label="折线图视图" title="折线图视图" @click="dailyView = 'line'"><ChartNoAxesCombined :size="15" /></button></div></div></header>
|
||||
<div v-if="dailyView === 'calendar'" class="daily-calendar">
|
||||
<div class="calendar-month-nav"><button type="button" aria-label="上个月" title="上个月" @click="shiftCalendarMonth(-1)"><ChevronLeft :size="17" /></button><strong>{{ calendarMonthLabel }}</strong><button type="button" aria-label="下个月" title="下个月" @click="shiftCalendarMonth(1)"><ChevronRight :size="17" /></button></div>
|
||||
<div class="calendar-weekdays"><span v-for="weekday in ['日','一','二','三','四','五','六']" :key="weekday">{{ weekday }}</span></div>
|
||||
<div class="calendar-grid">
|
||||
<span v-for="(cell, index) in calendarDays" :key="cell?.date ?? `blank-${index}`" class="calendar-cell" :class="{ blank: !cell }">
|
||||
<template v-if="cell"><b>{{ cell.day }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / maxDailyAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / maxDailyAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small></template>
|
||||
<template v-if="flowGranularity === 'day'">
|
||||
<div class="calendar-month-nav"><button type="button" aria-label="上个月" title="上个月" :disabled="calendarMonthStart?.getTime() === new Date(rangeBounds?.start.getFullYear() ?? 0, rangeBounds?.start.getMonth() ?? 0, 1).getTime()" @click="shiftCalendarMonth(-1)"><ChevronLeft :size="17" /></button><strong>{{ calendarMonthLabel }}</strong><button type="button" aria-label="下个月" title="下个月" :disabled="calendarMonthStart?.getMonth() === new Date(rangeBounds?.end.getFullYear() ?? 0, rangeBounds?.end.getMonth() ?? 0, 1).getMonth() && calendarMonthStart?.getFullYear() === new Date(rangeBounds?.end.getTime() ?? 0).getFullYear()" @click="shiftCalendarMonth(1)"><ChevronRight :size="17" /></button></div>
|
||||
<div class="calendar-weekdays"><span v-for="weekday in ['日','一','二','三','四','五','六']" :key="weekday">{{ weekday }}</span></div>
|
||||
<div class="calendar-grid">
|
||||
<span v-for="(cell, index) in calendarDays" :key="cell?.date ?? `blank-${index}`" class="calendar-cell" :class="{ blank: !cell }">
|
||||
<template v-if="cell"><b>{{ cell.day }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / maxDailyAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / maxDailyAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small></template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="period-calendar-grid">
|
||||
<span v-for="cell in flowGranularity === 'month' ? calendarMonthCells : calendarYearCells" :key="cell.date" class="period-calendar-cell">
|
||||
<b>{{ cell.label }}</b><i class="income" :style="{ width: `${Math.min(100, (cell.income / lineMaxAmount) * 100)}%` }"></i><i class="expense" :style="{ width: `${Math.min(100, (cell.expense / lineMaxAmount) * 100)}%` }"></i><small v-if="cell.income || cell.expense">{{ compactDailyAmount(cell.income || cell.expense) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
|
|
@ -680,6 +732,13 @@ function shiftCalendarMonth(offset: -1 | 1) {
|
|||
.calendar-cell i.income { background:#20a57d; }
|
||||
.calendar-cell i.expense { background:#ef6a4d; }
|
||||
.calendar-cell small { color:#7b8884; font-size:8px; line-height:1; }
|
||||
.period-calendar-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; }
|
||||
.period-calendar-cell { min-width:0; min-height:66px; display:grid; align-content:center; justify-items:center; gap:5px; border:1px solid #e6eeec; border-radius:7px; padding:7px 5px; background:#fff; }
|
||||
.period-calendar-cell b { color:#536762; font-size:11px; font-weight:680; }
|
||||
.period-calendar-cell i { width:0; max-width:80%; height:4px; display:block; border-radius:3px; }
|
||||
.period-calendar-cell i.income { background:#20a57d; }
|
||||
.period-calendar-cell i.expense { background:#ef6a4d; }
|
||||
.period-calendar-cell small { color:#7b8884; font-size:9px; line-height:1; }
|
||||
.daily-view-legend { display:flex; justify-content:center; gap:15px; margin-top:10px; font-size:10px; }
|
||||
.daily-view-legend span::before { display:inline-block; width:7px; height:7px; margin-right:4px; border-radius:50%; background:currentColor; content:""; }
|
||||
.daily-line-chart { padding:12px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue