From 322ee737fdfcec4827b05b0213722d4bb84fa068 Mon Sep 17 00:00:00 2001 From: openclaw Date: Thu, 23 Jul 2026 00:38:51 +0800 Subject: [PATCH] Improve statistics and category options --- apps/web/src/data/categories.ts | 89 +++++++++++++++- apps/web/src/data/ledgers.ts | 6 ++ apps/web/src/views/LedgerView.vue | 8 +- apps/web/src/views/LedgersView.vue | 6 +- apps/web/src/views/StatsView.vue | 156 +++++++++++++++++++++-------- packages/domain/src/index.ts | 6 ++ 6 files changed, 221 insertions(+), 50 deletions(-) diff --git a/apps/web/src/data/categories.ts b/apps/web/src/data/categories.ts index 7535772..7db94e7 100644 --- a/apps/web/src/data/categories.ts +++ b/apps/web/src/data/categories.ts @@ -2,23 +2,45 @@ import type { EntryType } from "@cents/domain"; import { Banknote, Bike, + BookOpen, BriefcaseBusiness, BusFront, CarTaxiFront, + ChartNoAxesCombined, + Clapperboard, Coffee, CookingPot, + Cookie, + CupSoda, + FileText, + Fuel, + Gamepad2, Gift, GraduationCap, + HandCoins, HeartPulse, House, + KeyRound, + Music2, + Package, + ParkingCircle, Plane, + ReceiptText, + RotateCcw, + School, + ShieldCheck, ShoppingBag, ShoppingBasket, + Shirt, Smartphone, Soup, + Sparkles, Ticket, + TrainFront, Utensils, - WalletCards, + Wifi, + Wrench, + Zap, type LucideIcon, } from "@lucide/vue"; @@ -40,7 +62,7 @@ export type 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 }, ]; @@ -56,6 +78,8 @@ export const categories: Record = { { id: "takeout", label: "外卖", color: "#f06a34", tint: "#fff0e8", icon: Soup }, { id: "restaurant", label: "堂食", color: "#e9572e", tint: "#ffede7", icon: CookingPot }, { 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 = { children: [ { id: "taxi", label: "打车", color: "#3478e5", tint: "#edf4ff", icon: CarTaxiFront }, { 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: "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 = { ], }, { 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: "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 }, ], income: [ { id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness }, { 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 }, ], }; -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 category of group) { if (category.id === categoryId) return category; diff --git a/apps/web/src/data/ledgers.ts b/apps/web/src/data/ledgers.ts index c265162..ade960b 100644 --- a/apps/web/src/data/ledgers.ts +++ b/apps/web/src/data/ledgers.ts @@ -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: "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: "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) { diff --git a/apps/web/src/views/LedgerView.vue b/apps/web/src/views/LedgerView.vue index 0fc35ac..1e5b2b6 100644 --- a/apps/web/src/views/LedgerView.vue +++ b/apps/web/src/views/LedgerView.vue @@ -18,7 +18,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import QuickEntryHost from "../components/QuickEntryHost.vue"; 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 { useEntryStore } from "../stores/entries"; import { useLedgerStore } from "../stores/ledgers"; @@ -179,6 +179,10 @@ function categoryLabel(entry: LedgerEntry) { 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() { participantOpen.value = true; void ledgerStore.loadCurrentMembers(); @@ -342,7 +346,7 @@ async function shareLedger() { background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'), }" > - +
{{ entry.note || categoryLabel(entry) }} diff --git a/apps/web/src/views/LedgersView.vue b/apps/web/src/views/LedgersView.vue index cb8f7db..07837bc 100644 --- a/apps/web/src/views/LedgersView.vue +++ b/apps/web/src/views/LedgersView.vue @@ -2,7 +2,7 @@ import type { CurrencyCode, LedgerThemeId } from "@cents/domain"; import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/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 { ApiError } from "../data/api"; import { ledgerTheme, ledgerThemes } from "../data/ledgers"; @@ -10,6 +10,7 @@ import { useEntryStore } from "../stores/entries"; import { useLedgerStore } from "../stores/ledgers"; const router = useRouter(); +const route = useRoute(); const entryStore = useEntryStore(); const ledgerStore = useLedgerStore(); const createOpen = ref(false); @@ -33,7 +34,8 @@ onMounted(async () => { async function selectLedger(ledgerId: string) { await ledgerStore.setCurrentLedger(ledgerId); - await router.push("/"); + const returnTo = route.query.returnTo === "/stats" ? "/stats" : "/"; + await router.push(returnTo); } function openCreate() { diff --git a/apps/web/src/views/StatsView.vue b/apps/web/src/views/StatsView.vue index 3ead40d..d7b14c6 100644 --- a/apps/web/src/views/StatsView.vue +++ b/apps/web/src/views/StatsView.vue @@ -5,35 +5,68 @@ import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import QuickEntryHost from "../components/QuickEntryHost.vue"; import { findCategory } from "../data/categories"; -import { useAuthStore } from "../stores/auth"; +import { ledgerTheme } from "../data/ledgers"; import { useEntryStore } from "../stores/entries"; import { useLedgerStore } from "../stores/ledgers"; type StatsTab = "flow" | "category" | "member"; -type StatsScope = "ledger" | "personal"; +type RangeMode = "month" | "year" | "custom"; const router = useRouter(); -const authStore = useAuthStore(); const entryStore = useEntryStore(); const ledgerStore = useLedgerStore(); const activeTab = ref("flow"); -const scope = ref("ledger"); +const rangeMode = ref("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 date = new Date(); - return `${date.getFullYear()}年${date.getMonth() + 1}月`; +const yearOptions = computed(() => { + const years = new Set(); + 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 current = new Date(); + const bounds = rangeBounds.value; + if (!bounds) return []; return entryStore.entries.filter((entry) => { const occurredAt = new Date(entry.occurredAt); - const inScope = scope.value === "personal" - ? entry.ownerId === authStore.user?.id - : entry.ledgerIds.includes(ledgerStore.currentLedgerId); - return inScope - && occurredAt.getFullYear() === current.getFullYear() - && occurredAt.getMonth() === current.getMonth(); + return entry.ledgerIds.includes(ledgerStore.currentLedgerId) + && occurredAt >= bounds.start + && occurredAt < bounds.end; }); }); @@ -43,10 +76,11 @@ const dailyStats = computed(() => { const groups = new Map(); for (const entry of entries.value) { const date = new Date(entry.occurredAt); - const key = `${date.getMonth() + 1}/${date.getDate()}`; + const key = toDateInput(date); 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(() => @@ -99,33 +133,65 @@ function summarize(items: LedgerEntry[]) { 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()}`; +}