cents/apps/web/src/views/StatsView.vue

320 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { LedgerEntry } from "@cents/domain";
import { ChevronDown, CircleUserRound, TrendingDown, TrendingUp, WalletCards } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type StatsTab = "flow" | "category" | "member";
type RangeMode = "month" | "year" | "custom";
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const activeTab = ref<StatsTab>("flow");
const rangeMode = ref<RangeMode>("month");
const now = new Date();
const selectedMonth = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
const selectedYear = ref(now.getFullYear());
const customStart = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`);
const customEnd = ref(toDateInput(now));
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
const yearOptions = computed(() => {
const years = new Set<number>();
for (let year = now.getFullYear() + 1; year >= now.getFullYear() - 5; year -= 1) years.add(year);
for (const entry of entryStore.entries) years.add(new Date(entry.occurredAt).getFullYear());
return [...years].sort((left, right) => right - left);
});
const rangeBounds = computed(() => {
if (rangeMode.value === "month") {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
if (!match) return null;
const start = new Date(Number(match[1]), Number(match[2]) - 1, 1);
return { start, end: new Date(start.getFullYear(), start.getMonth() + 1, 1) };
}
if (rangeMode.value === "year") {
return { start: new Date(selectedYear.value, 0, 1), end: new Date(selectedYear.value + 1, 0, 1) };
}
const start = fromDateInput(customStart.value);
const selectedEnd = fromDateInput(customEnd.value);
if (!start || !selectedEnd || start > selectedEnd) return null;
const end = new Date(selectedEnd);
end.setDate(end.getDate() + 1);
return { start, end };
});
const rangeLabel = computed(() => {
if (rangeMode.value === "month") {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
return match ? `${match[1]}${Number(match[2])}` : "所选月份";
}
if (rangeMode.value === "year") return `${selectedYear.value}`;
if (!rangeBounds.value) return "自定义日期";
return `${formatDateLabel(customStart.value)}${formatDateLabel(customEnd.value)}`;
});
const entries = computed(() => {
const bounds = rangeBounds.value;
if (!bounds) return [];
return entryStore.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
&& occurredAt >= bounds.start
&& occurredAt < bounds.end;
});
});
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 dailyStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of convertedEntries.value) {
const date = new Date(entry.occurredAt);
const key = toDateInput(date);
groups.set(key, [...(groups.get(key) ?? []), entry]);
}
return Array.from(groups, ([date, items]) => ({ date, label: dailyLabel(date), ...summarize(items) }))
.sort((left, right) => right.date.localeCompare(left.date));
});
const maxDailyAmount = computed(() =>
Math.max(1, ...dailyStats.value.flatMap((day) => [day.income, day.expense])),
);
const categoryStats = computed(() => {
const groups = new Map<string, { amount: number; count: number }>();
for (const entry of convertedEntries.value) {
const current = groups.get(entry.categoryId) ?? { amount: 0, count: 0 };
current.amount += entry.baseAmount!;
current.count += 1;
groups.set(entry.categoryId, current);
}
return Array.from(groups, ([categoryId, summary]) => ({
categoryId,
category: findCategory(categoryId),
...summary,
})).sort((left, right) => right.amount - left.amount);
});
const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map((item) => item.amount)));
const memberStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of convertedEntries.value) groups.set(entry.createdBy, [...(groups.get(entry.createdBy) ?? []), entry]);
return Array.from(groups, ([userId, items]) => ({
userId,
name: ledgerStore.memberName(userId),
count: items.length,
...summarize(items),
})).sort((left, right) => right.count - left.count);
});
onMounted(async () => {
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
});
function summarize(items: LedgerEntry[]) {
return items.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 },
);
}
function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function toDateInput(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function fromDateInput(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateLabel(value: string) {
const date = fromDateInput(value);
return date ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : value;
}
function dailyLabel(value: string) {
const date = fromDateInput(value);
if (!date) return value;
const crossesYear = rangeBounds.value && rangeBounds.value.start.getFullYear() !== new Date(rangeBounds.value.end.getTime() - 1).getFullYear();
return crossesYear ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : `${date.getMonth() + 1}/${date.getDate()}`;
}
</script>
<template>
<main class="app-shell stats-shell">
<header class="stats-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
<strong>统计</strong>
<button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: '/stats' } })">
{{ ledgerStore.currentLedger?.name ?? "选择账本" }}
<ChevronDown :size="15" />
</button>
</header>
<div class="stats-scroll">
<section class="stats-range" aria-label="统计时间范围">
<div class="stats-range-modes" role="group" aria-label="时间范围类型">
<button type="button" :class="{ active: rangeMode === 'month' }" @click="rangeMode = 'month'"></button>
<button type="button" :class="{ active: rangeMode === 'year' }" @click="rangeMode = 'year'"></button>
<button type="button" :class="{ active: rangeMode === 'custom' }" @click="rangeMode = 'custom'">自定义</button>
</div>
<input v-if="rangeMode === 'month'" v-model="selectedMonth" type="month" aria-label="选择月份" />
<select v-else-if="rangeMode === 'year'" v-model="selectedYear" aria-label="选择年份">
<option v-for="year in yearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<div v-else class="stats-custom-range">
<label><span>开始</span><input v-model="customStart" type="date" aria-label="开始日期" :max="customEnd || undefined" /></label>
<label><span>结束</span><input v-model="customEnd" type="date" aria-label="结束日期" :min="customStart || undefined" /></label>
</div>
<p v-if="rangeMode === 'custom' && !rangeBounds" role="alert">开始日期不能晚于结束日期</p>
</section>
<section class="stats-summary" :aria-label="`${rangeLabel}统计`">
<div class="balance">
<span>结余</span>
<strong>¥ {{ money(totals.income - totals.expense) }}</strong>
</div>
<div class="income"><span><TrendingUp :size="17" />收入</span><strong>{{ money(totals.income) }}</strong></div>
<div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
</section>
<div class="stats-conversion-slot" aria-live="polite">
<p v-if="pendingConversionCount" class="stats-conversion-notice" role="status">
{{ pendingConversionCount }} 条外币账目等待换算,暂未计入人民币统计
</p>
</div>
<div class="stats-tabs" role="tablist" aria-label="统计维度">
<button type="button" :class="{ active: activeTab === 'flow' }" @click="activeTab = 'flow'">收支</button>
<button type="button" :class="{ active: activeTab === 'category' }" @click="activeTab = 'category'">分类</button>
<button type="button" :class="{ active: activeTab === 'member' }" @click="activeTab = 'member'">记录者</button>
</div>
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
<header><strong>每日收支</strong><span>{{ entries.length }} 笔</span></header>
<div v-for="day in dailyStats" :key="day.date" class="daily-stat-row">
<span>{{ day.label }}</span>
<div class="daily-bars">
<i class="income" :style="{ width: `${(day.income / maxDailyAmount) * 100}%` }"></i>
<i class="expense" :style="{ width: `${(day.expense / maxDailyAmount) * 100}%` }"></i>
</div>
<div><small class="income">+{{ money(day.income) }}</small><small class="expense">{{ money(day.expense) }}</small></div>
</div>
</section>
<section v-else-if="activeTab === 'category'" class="stats-section" aria-label="分类统计">
<header><strong>分类金额</strong><span>{{ categoryStats.length }} 类</span></header>
<div v-for="item in categoryStats" :key="item.categoryId" class="category-stat-row">
<span
class="category-stat-icon"
:style="{ color: item.category?.color ?? '#087f72', background: item.category?.tint ?? '#e9f7f3' }"
>
<component :is="item.category?.icon ?? WalletCards" :size="19" />
</span>
<div>
<strong>{{ item.category?.label ?? "未分类" }}</strong>
<i :style="{ width: `${(item.amount / maxCategoryAmount) * 100}%`, background: item.category?.color ?? '#087f72' }"></i>
</div>
<span><strong>{{ money(item.amount) }}</strong><small>{{ item.count }} 笔</small></span>
</div>
</section>
<section v-else class="stats-section" aria-label="记录者统计">
<header><strong>记录者</strong><span>{{ memberStats.length }} 人</span></header>
<div v-for="member in memberStats" :key="member.userId" class="member-stat-row">
<span><CircleUserRound :size="22" /></span>
<div><strong>{{ member.name }}</strong><small>{{ member.count }} 笔记录</small></div>
<div><small class="income">收入 {{ money(member.income) }}</small><small class="expense">支出 {{ money(member.expense) }}</small></div>
</div>
</section>
<div v-if="rangeBounds && !entries.length" class="stats-empty">{{ rangeLabel }}还没有流水</div>
</div>
<QuickEntryHost />
</main>
</template>
<style scoped>
.stats-shell { background: #f5f8f7; }
.stats-header { height:74px; display:flex; align-items:center; justify-content:space-between; padding:14px 18px 8px; background:var(--ledger-gradient, #087f72); color:#fff; }
.stats-header > strong { font-size: 20px; }
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-range { display:grid; gap:10px; padding:12px 0; }
.stats-range-modes { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
.stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
.stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.stats-range > input,.stats-range > select,.stats-custom-range input { width:100%; min-width:0; height:40px; border:1px solid #d2dfdc; border-radius:7px; padding:0 10px; background:#fff; color:#344640; font-size:13px; }
.stats-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.stats-custom-range label { min-width:0; display:grid; gap:4px; }
.stats-custom-range label > span { color:#7b8884; font-size:10px; }
.stats-range > p { margin:0; color:#c74d39; font-size:11px; }
.stats-summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
.stats-summary > div { min-width:0; display:grid; gap:5px; padding:12px; }
.stats-summary .balance { grid-column:1 / -1; border-bottom:1px solid #e1ebe8; padding-top:15px; padding-bottom:15px; }
.stats-summary > div:last-child { border-left:1px solid #e1ebe8; }
.stats-summary span { display:flex; align-items:center; gap:5px; color:#788581; font-size:11px; }
.stats-summary strong { min-width:0; font-size:15px; line-height:1.25; overflow-wrap:anywhere; font-variant-numeric:tabular-nums; }
.stats-summary .balance strong { font-size:24px; }
.stats-summary .income { color: #0d8b67; }
.stats-summary .expense { color: #d84c36; }
.stats-conversion-slot { height:34px; margin-top:8px; overflow:hidden; }
.stats-conversion-notice { margin:0; border-left:3px solid #d69a32; padding:7px 9px; background:#fff8e9; color:#755719; font-size:11px; }
.stats-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
.stats-tabs button { height: 38px; border: 0; border-radius: 6px; background: transparent; color: #71807c; font-size: 13px; font-weight: 680; }
.stats-tabs button.active { background: #fff; color: #087f72; box-shadow: 0 2px 7px rgba(28,52,47,.12); }
.stats-section { border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; background: #fff; }
.stats-section > header { height: 48px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4ece9; padding: 0 12px; }
.stats-section > header strong { font-size: 14px; }
.stats-section > header span { color: #7b8884; font-size: 11px; }
.daily-stat-row { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) 82px; align-items: center; gap: 9px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
.daily-stat-row:last-child { border-bottom: 0; }
.daily-stat-row > span { color: #667570; font-size: 11px; }
.daily-bars { display: grid; gap: 5px; }
.daily-bars i { height: 5px; min-width: 2px; border-radius: 3px; }
.daily-bars i.income { background: #20a57d; }
.daily-bars i.expense { background: #ef6a4d; }
.daily-stat-row > div:last-child { display: grid; text-align: right; }
.daily-stat-row small { font-size: 10px; font-variant-numeric: tabular-nums; }
.income { color: #0d8b67; }
.expense { color: #d84c36; }
.category-stat-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
.category-stat-row:last-child { border-bottom: 0; }
.category-stat-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; }
.category-stat-row > div { min-width: 0; display: grid; gap: 7px; }
.category-stat-row > div strong { font-size: 13px; }
.category-stat-row > div i { height: 4px; min-width: 2px; border-radius: 2px; }
.category-stat-row > span:last-child { display: grid; text-align: right; }
.category-stat-row > span:last-child strong { font-size: 12px; }
.category-stat-row small { color: #7d8986; font-size: 10px; }
.member-stat-row { min-height: 72px; display: grid; grid-template-columns: 42px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; }
.member-stat-row > span { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: #edf4ff; color: #3478e5; }
.member-stat-row > div { display: grid; }
.member-stat-row > div:last-child { text-align: right; }
.member-stat-row strong { font-size: 14px; }
.member-stat-row small { color: #7b8884; font-size: 10px; }
.member-stat-row small.income { color: #0d8b67; }
.member-stat-row small.expense { color: #d84c36; }
.stats-empty { min-height: 180px; display: grid; place-items: center; color: #7b8884; font-size: 13px; }
@media (max-width:360px) { .stats-scroll { padding-right:12px; padding-left:12px; } .stats-custom-range { grid-template-columns:1fr; } .stats-custom-range input { padding:0 8px; font-size:12px; } .stats-summary > div { padding-right:10px; padding-left:10px; } }
</style>