Improve statistics and category options

This commit is contained in:
openclaw 2026-07-23 00:38:51 +08:00
parent 5d03fd2a11
commit 322ee737fd
6 changed files with 221 additions and 50 deletions

View File

@ -2,23 +2,45 @@ import type { EntryType } from "@cents/domain";
import { import {
Banknote, Banknote,
Bike, Bike,
BookOpen,
BriefcaseBusiness, BriefcaseBusiness,
BusFront, BusFront,
CarTaxiFront, CarTaxiFront,
ChartNoAxesCombined,
Clapperboard,
Coffee, Coffee,
CookingPot, CookingPot,
Cookie,
CupSoda,
FileText,
Fuel,
Gamepad2,
Gift, Gift,
GraduationCap, GraduationCap,
HandCoins,
HeartPulse, HeartPulse,
House, House,
KeyRound,
Music2,
Package,
ParkingCircle,
Plane, Plane,
ReceiptText,
RotateCcw,
School,
ShieldCheck,
ShoppingBag, ShoppingBag,
ShoppingBasket, ShoppingBasket,
Shirt,
Smartphone, Smartphone,
Soup, Soup,
Sparkles,
Ticket, Ticket,
TrainFront,
Utensils, Utensils,
WalletCards, Wifi,
Wrench,
Zap,
type LucideIcon, type LucideIcon,
} from "@lucide/vue"; } from "@lucide/vue";
@ -40,7 +62,7 @@ export type EntryTypeOption = {
}; };
export const entryTypes: EntryTypeOption[] = [ export const entryTypes: EntryTypeOption[] = [
{ id: "expense", label: "支出", color: "#ef5b3f", tint: "#fff0ec", icon: WalletCards }, { id: "expense", label: "支出", color: "#ef5b3f", tint: "#fff0ec", icon: ReceiptText },
{ id: "income", label: "收入", color: "#16966f", tint: "#e9faf4", icon: Banknote }, { id: "income", label: "收入", color: "#16966f", tint: "#e9faf4", icon: Banknote },
]; ];
@ -56,6 +78,8 @@ export const categories: Record<EntryType, Category[]> = {
{ id: "takeout", label: "外卖", color: "#f06a34", tint: "#fff0e8", icon: Soup }, { id: "takeout", label: "外卖", color: "#f06a34", tint: "#fff0e8", icon: Soup },
{ id: "restaurant", label: "堂食", color: "#e9572e", tint: "#ffede7", icon: CookingPot }, { id: "restaurant", label: "堂食", color: "#e9572e", tint: "#ffede7", icon: CookingPot },
{ id: "coffee", label: "咖啡", color: "#9b6a48", tint: "#f8eee6", icon: Coffee }, { id: "coffee", label: "咖啡", color: "#9b6a48", tint: "#f8eee6", icon: Coffee },
{ id: "snacks", label: "零食", color: "#c98535", tint: "#fff4e5", icon: Cookie },
{ id: "drinks", label: "饮品", color: "#2d8f9e", tint: "#eaf8fa", icon: CupSoda },
], ],
}, },
{ {
@ -67,7 +91,10 @@ export const categories: Record<EntryType, Category[]> = {
children: [ children: [
{ id: "taxi", label: "打车", color: "#3478e5", tint: "#edf4ff", icon: CarTaxiFront }, { id: "taxi", label: "打车", color: "#3478e5", tint: "#edf4ff", icon: CarTaxiFront },
{ id: "public-transit", label: "公交地铁", color: "#2563c7", tint: "#edf4ff", icon: Ticket }, { id: "public-transit", label: "公交地铁", color: "#2563c7", tint: "#edf4ff", icon: Ticket },
{ id: "train", label: "火车", color: "#4869b1", tint: "#eef2fb", icon: TrainFront },
{ id: "cycling", label: "骑行", color: "#168c87", tint: "#e8f8f6", icon: Bike }, { id: "cycling", label: "骑行", color: "#168c87", tint: "#e8f8f6", icon: Bike },
{ id: "fuel", label: "加油", color: "#6f6b58", tint: "#f3f2ed", icon: Fuel },
{ id: "parking", label: "停车", color: "#4f7190", tint: "#edf3f7", icon: ParkingCircle },
], ],
}, },
{ {
@ -83,18 +110,72 @@ export const categories: Record<EntryType, Category[]> = {
], ],
}, },
{ id: "shopping", label: "购物", color: "#d84486", tint: "#fff0f7", icon: ShoppingBag }, { id: "shopping", label: "购物", color: "#d84486", tint: "#fff0f7", icon: ShoppingBag },
{
id: "housing",
label: "住房",
color: "#387b70",
tint: "#eaf6f3",
icon: House,
children: [
{ id: "rent", label: "房租房贷", color: "#387b70", tint: "#eaf6f3", icon: KeyRound },
{ id: "utilities", label: "水电燃气", color: "#b27a22", tint: "#fff7e7", icon: Zap },
{ id: "maintenance", label: "维修", color: "#697970", tint: "#f0f4f2", icon: Wrench },
],
},
{
id: "entertainment",
label: "娱乐",
color: "#7958b3",
tint: "#f3effb",
icon: Gamepad2,
children: [
{ id: "games", label: "游戏", color: "#7958b3", tint: "#f3effb", icon: Gamepad2 },
{ id: "movies", label: "电影", color: "#a74d65", tint: "#faeef2", icon: Clapperboard },
{ id: "music", label: "音乐", color: "#4f70aa", tint: "#edf2fa", icon: Music2 },
],
},
{ id: "health", label: "医疗", color: "#df3f5f", tint: "#fff0f2", icon: HeartPulse }, { id: "health", label: "医疗", color: "#df3f5f", tint: "#fff0f2", icon: HeartPulse },
{
id: "education",
label: "教育",
color: "#4869b1",
tint: "#edf2fb",
icon: GraduationCap,
children: [
{ id: "books", label: "书籍", color: "#6b5c4c", tint: "#f5f0ea", icon: BookOpen },
{ id: "courses", label: "课程", color: "#4869b1", tint: "#edf2fb", icon: School },
],
},
{
id: "bills",
label: "账单",
color: "#526a78",
tint: "#edf2f4",
icon: FileText,
children: [
{ id: "mobile", label: "话费", color: "#526a78", tint: "#edf2f4", icon: Smartphone },
{ id: "internet", label: "宽带网络", color: "#3478a5", tint: "#edf5f9", icon: Wifi },
{ id: "insurance", label: "保险", color: "#477866", tint: "#edf6f2", icon: ShieldCheck },
],
},
{ id: "clothing", label: "服饰", color: "#b14e79", tint: "#faeef4", icon: Shirt },
{ id: "beauty", label: "美容", color: "#b75e91", tint: "#fbf0f6", icon: Sparkles },
{ id: "delivery", label: "快递", color: "#9a6b35", tint: "#f8f1e8", icon: Package },
{ id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane }, { id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane },
], ],
income: [ income: [
{ id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness }, { id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness },
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift }, { id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: GraduationCap }, { id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins },
{ id: "investment", label: "投资收益", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined },
{ id: "refund", label: "退款报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw },
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote }, { id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
], ],
}; };
export function findCategory(categoryId: string): Category | undefined { export function findCategory(categoryId: string): Category | EntryTypeOption | undefined {
const entryType = entryTypes.find((type) => type.id === categoryId);
if (entryType) return entryType;
for (const group of Object.values(categories)) { for (const group of Object.values(categories)) {
for (const category of group) { for (const category of group) {
if (category.id === categoryId) return category; if (category.id === categoryId) return category;

View File

@ -12,6 +12,12 @@ export const ledgerThemes: ReadonlyArray<{
{ id: "berry", name: "莓果", accent: ledgerThemeAccents.berry, gradient: "linear-gradient(135deg, #a84378 0%, #71468d 52%, #40578a 100%)" }, { id: "berry", name: "莓果", accent: ledgerThemeAccents.berry, gradient: "linear-gradient(135deg, #a84378 0%, #71468d 52%, #40578a 100%)" },
{ id: "meadow", name: "森野", accent: ledgerThemeAccents.meadow, gradient: "linear-gradient(135deg, #25805f 0%, #4e794b 54%, #766b35 100%)" }, { id: "meadow", name: "森野", accent: ledgerThemeAccents.meadow, gradient: "linear-gradient(135deg, #25805f 0%, #4e794b 54%, #766b35 100%)" },
{ id: "graphite", name: "暮色", accent: ledgerThemeAccents.graphite, gradient: "linear-gradient(135deg, #35585d 0%, #4e556e 52%, #303f55 100%)" }, { id: "graphite", name: "暮色", accent: ledgerThemeAccents.graphite, gradient: "linear-gradient(135deg, #35585d 0%, #4e556e 52%, #303f55 100%)" },
{ id: "sunrise", name: "晨曦", accent: ledgerThemeAccents.sunrise, gradient: "linear-gradient(135deg, #d18a45 0%, #b45f4a 50%, #82506f 100%)" },
{ id: "lagoon", name: "泻湖", accent: ledgerThemeAccents.lagoon, gradient: "linear-gradient(135deg, #15988b 0%, #177f8f 50%, #3f5f91 100%)" },
{ id: "iris", name: "鸢尾", accent: ledgerThemeAccents.iris, gradient: "linear-gradient(135deg, #5b70b8 0%, #7061b5 50%, #a0508d 100%)" },
{ id: "sky", name: "晴空", accent: ledgerThemeAccents.sky, gradient: "linear-gradient(135deg, #2592a6 0%, #327ba0 52%, #49639b 100%)" },
{ id: "olive", name: "山岚", accent: ledgerThemeAccents.olive, gradient: "linear-gradient(135deg, #4c8168 0%, #64784a 52%, #8a703e 100%)" },
{ id: "midnight", name: "夜航", accent: ledgerThemeAccents.midnight, gradient: "linear-gradient(135deg, #315d70 0%, #47577f 52%, #684b78 100%)" },
]; ];
export function ledgerTheme(themeId?: string) { export function ledgerTheme(themeId?: string) {

View File

@ -18,7 +18,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue"; import QuickEntryHost from "../components/QuickEntryHost.vue";
import { apiRequest, ApiError } from "../data/api"; import { apiRequest, ApiError } from "../data/api";
import { findCategory, findCategoryPath } from "../data/categories"; import { entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerTheme } from "../data/ledgers"; import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; import { useLedgerStore } from "../stores/ledgers";
@ -179,6 +179,10 @@ function categoryLabel(entry: LedgerEntry) {
return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入"); return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入");
} }
function categoryIcon(entry: LedgerEntry) {
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((type) => type.id === entry.type)?.icon ?? BookOpen;
}
function openParticipantSheet() { function openParticipantSheet() {
participantOpen.value = true; participantOpen.value = true;
void ledgerStore.loadCurrentMembers(); void ledgerStore.loadCurrentMembers();
@ -342,7 +346,7 @@ async function shareLedger() {
background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'), background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
}" }"
> >
<component :is="findCategory(entry.categoryId)?.icon ?? BookOpen" :size="21" /> <component :is="categoryIcon(entry)" :size="21" />
</div> </div>
<div class="entry-copy"> <div class="entry-copy">
<strong>{{ entry.note || categoryLabel(entry) }}</strong> <strong>{{ entry.note || categoryLabel(entry) }}</strong>

View File

@ -2,7 +2,7 @@
import type { CurrencyCode, LedgerThemeId } from "@cents/domain"; import type { CurrencyCode, LedgerThemeId } from "@cents/domain";
import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue"; import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue"; import QuickEntryHost from "../components/QuickEntryHost.vue";
import { ApiError } from "../data/api"; import { ApiError } from "../data/api";
import { ledgerTheme, ledgerThemes } from "../data/ledgers"; import { ledgerTheme, ledgerThemes } from "../data/ledgers";
@ -10,6 +10,7 @@ import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; import { useLedgerStore } from "../stores/ledgers";
const router = useRouter(); const router = useRouter();
const route = useRoute();
const entryStore = useEntryStore(); const entryStore = useEntryStore();
const ledgerStore = useLedgerStore(); const ledgerStore = useLedgerStore();
const createOpen = ref(false); const createOpen = ref(false);
@ -33,7 +34,8 @@ onMounted(async () => {
async function selectLedger(ledgerId: string) { async function selectLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId); await ledgerStore.setCurrentLedger(ledgerId);
await router.push("/"); const returnTo = route.query.returnTo === "/stats" ? "/stats" : "/";
await router.push(returnTo);
} }
function openCreate() { function openCreate() {

View File

@ -5,35 +5,68 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue"; import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories"; import { findCategory } from "../data/categories";
import { useAuthStore } from "../stores/auth"; import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; import { useLedgerStore } from "../stores/ledgers";
type StatsTab = "flow" | "category" | "member"; type StatsTab = "flow" | "category" | "member";
type StatsScope = "ledger" | "personal"; type RangeMode = "month" | "year" | "custom";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore();
const entryStore = useEntryStore(); const entryStore = useEntryStore();
const ledgerStore = useLedgerStore(); const ledgerStore = useLedgerStore();
const activeTab = ref<StatsTab>("flow"); const activeTab = ref<StatsTab>("flow");
const scope = ref<StatsScope>("ledger"); 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 monthLabel = computed(() => { const yearOptions = computed(() => {
const date = new Date(); const years = new Set<number>();
return `${date.getFullYear()}${date.getMonth() + 1}`; 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 entries = computed(() => {
const current = new Date(); const bounds = rangeBounds.value;
if (!bounds) return [];
return entryStore.entries.filter((entry) => { return entryStore.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt); const occurredAt = new Date(entry.occurredAt);
const inScope = scope.value === "personal" return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
? entry.ownerId === authStore.user?.id && occurredAt >= bounds.start
: entry.ledgerIds.includes(ledgerStore.currentLedgerId); && occurredAt < bounds.end;
return inScope
&& occurredAt.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth();
}); });
}); });
@ -43,10 +76,11 @@ const dailyStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>(); const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) { for (const entry of entries.value) {
const date = new Date(entry.occurredAt); const date = new Date(entry.occurredAt);
const key = `${date.getMonth() + 1}/${date.getDate()}`; const key = toDateInput(date);
groups.set(key, [...(groups.get(key) ?? []), entry]); groups.set(key, [...(groups.get(key) ?? []), entry]);
} }
return Array.from(groups, ([label, items]) => ({ label, ...summarize(items) })).reverse(); return Array.from(groups, ([date, items]) => ({ date, label: dailyLabel(date), ...summarize(items) }))
.sort((left, right) => right.date.localeCompare(left.date));
}); });
const maxDailyAmount = computed(() => const maxDailyAmount = computed(() =>
@ -99,33 +133,65 @@ function summarize(items: LedgerEntry[]) {
function money(value: number) { function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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> </script>
<template> <template>
<main class="app-shell stats-shell"> <main class="app-shell stats-shell">
<header class="stats-header"> <header class="stats-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
<strong>统计</strong> <strong>统计</strong>
<button type="button" @click="router.push('/ledgers')"> <button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: '/stats' } })">
{{ ledgerStore.currentLedger?.name ?? "选择账本" }} {{ ledgerStore.currentLedger?.name ?? "选择账本" }}
<ChevronDown :size="15" /> <ChevronDown :size="15" />
</button> </button>
</header> </header>
<div class="stats-scroll"> <div class="stats-scroll">
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button> <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>
<div class="stats-scope" role="group" aria-label="统计范围"> <section class="stats-summary" :aria-label="`${rangeLabel}统计`">
<button type="button" :class="{ active: scope === 'ledger' }" @click="scope = 'ledger'">当前账本</button> <div class="balance">
<button type="button" :class="{ active: scope === 'personal' }" @click="scope = 'personal'">我的全部</button>
</div>
<section class="stats-summary" aria-label="本月统计">
<div>
<span>结余</span> <span>结余</span>
<strong>¥ {{ money(totals.income - totals.expense) }}</strong> <strong>¥ {{ money(totals.income - totals.expense) }}</strong>
</div> </div>
<div class="income"><TrendingUp :size="18" /><span>收入</span><strong>{{ money(totals.income) }}</strong></div> <div class="income"><span><TrendingUp :size="17" />收入</span><strong>{{ money(totals.income) }}</strong></div>
<div class="expense"><TrendingDown :size="18" /><span>支出</span><strong>{{ money(totals.expense) }}</strong></div> <div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
</section> </section>
<div class="stats-tabs" role="tablist" aria-label="统计维度"> <div class="stats-tabs" role="tablist" aria-label="统计维度">
@ -136,7 +202,7 @@ function money(value: number) {
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支"> <section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
<header><strong>每日收支</strong><span>{{ entries.length }} </span></header> <header><strong>每日收支</strong><span>{{ entries.length }} </span></header>
<div v-for="day in dailyStats" :key="day.label" class="daily-stat-row"> <div v-for="day in dailyStats" :key="day.date" class="daily-stat-row">
<span>{{ day.label }}</span> <span>{{ day.label }}</span>
<div class="daily-bars"> <div class="daily-bars">
<i class="income" :style="{ width: `${(day.income / maxDailyAmount) * 100}%` }"></i> <i class="income" :style="{ width: `${(day.income / maxDailyAmount) * 100}%` }"></i>
@ -172,7 +238,7 @@ function money(value: number) {
</div> </div>
</section> </section>
<div v-if="!entries.length" class="stats-empty">当前月份还没有流水</div> <div v-if="rangeBounds && !entries.length" class="stats-empty">{{ rangeLabel }}还没有流水</div>
</div> </div>
<QuickEntryHost /> <QuickEntryHost />
@ -181,20 +247,26 @@ function money(value: number) {
<style scoped> <style scoped>
.stats-shell { background: #f5f8f7; } .stats-shell { background: #f5f8f7; }
.stats-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 8px; background: #087f72; color: #fff; } .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 > 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-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-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; } .stats-range { display:grid; gap:10px; padding:12px 0; }
.stats-scope { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:0 0 12px; border-radius:8px; padding:4px; background:#e7eeec; } .stats-range-modes { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
.stats-scope button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; } .stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
.stats-scope button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); } .stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; } .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-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; } .stats-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.stats-summary > div:first-child { border-left: 0; } .stats-custom-range label { min-width:0; display:grid; gap:4px; }
.stats-summary span { color: #788581; font-size: 11px; } .stats-custom-range label > span { color:#7b8884; font-size:10px; }
.stats-summary strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } .stats-range > p { margin:0; color:#c74d39; font-size:11px; }
.stats-summary > div:first-child strong { font-size: 19px; } .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 .income { color: #0d8b67; }
.stats-summary .expense { color: #d84c36; } .stats-summary .expense { color: #d84c36; }
.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 { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
@ -233,5 +305,5 @@ function money(value: number) {
.member-stat-row small.income { color: #0d8b67; } .member-stat-row small.income { color: #0d8b67; }
.member-stat-row small.expense { color: #d84c36; } .member-stat-row small.expense { color: #d84c36; }
.stats-empty { min-height: 180px; display: grid; place-items: center; color: #7b8884; font-size: 13px; } .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-summary > div { padding: 0 8px; } .stats-summary > div:first-child strong { font-size: 17px; } } @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> </style>

View File

@ -11,6 +11,12 @@ export const ledgerThemeAccents = {
berry: "#a84378", berry: "#a84378",
meadow: "#2f8060", meadow: "#2f8060",
graphite: "#526a78", graphite: "#526a78",
sunrise: "#b45f4a",
lagoon: "#177f8f",
iris: "#7061b5",
sky: "#327ba0",
olive: "#64784a",
midnight: "#47577f",
} as const; } as const;
export type LedgerThemeId = keyof typeof ledgerThemeAccents; export type LedgerThemeId = keyof typeof ledgerThemeAccents;