320 lines
17 KiB
Vue
320 lines
17 KiB
Vue
<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 { position:relative; 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 { position:absolute; top:0; right:16px; left:16px; z-index:3; height:0; overflow:visible; pointer-events:none; }
|
||
.stats-conversion-notice { margin:0; border-left:3px solid #d69a32; padding:7px 9px; background:#fff8e9; color:#755719; font-size:11px; box-shadow:0 2px 8px rgba(58,43,17,.08); pointer-events:auto; }
|
||
.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>
|