feat: improve statistics views and ledger themes

This commit is contained in:
openclaw 2026-07-26 03:16:03 +08:00
parent 4f30bd9830
commit a196a04668
16 changed files with 1720 additions and 182 deletions

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { LedgerIconKey } from "@cents/domain";
import { ledgerIconPresets } from "../data/ledger-icons";
defineProps<{ modelValue: LedgerIconKey }>();
const emit = defineEmits<{ "update:modelValue": [value: LedgerIconKey] }>();
const iconOptions = ledgerIconPresets.flatMap((preset) => preset.options);
</script>
<template>
<div class="ledger-icon-picker">
<button
v-for="option in iconOptions"
:key="option.key"
type="button"
:class="{ active: modelValue === option.key }"
:aria-label="option.label"
:title="option.label"
@click="emit('update:modelValue', option.key)"
>
<component :is="option.icon" :size="22" />
</button>
</div>
</template>
<style scoped>
.ledger-icon-picker { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; width:100%; }
.ledger-icon-picker button { width:100%; aspect-ratio:1; min-width:0; display:grid; place-items:center; border:1px solid #dce7e4; border-radius:9px; background:#f8fbfa; color:#58716a; }
.ledger-icon-picker button :deep(svg) { width:clamp(18px,5vw,25px); height:clamp(18px,5vw,25px); }
.ledger-icon-picker button.active { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; }
</style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { CurrencyCode } from "@cents/domain";
import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@lucide/vue";
import { BookOpen, Check, CircleUserRound, LibraryBig, Plus, Search } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { createEntry } from "../data/entries";
@ -49,13 +49,13 @@ async function saveEntry(input: {
<template>
<nav class="bottom-nav" aria-label="主导航">
<RouterLink to="/" :class="{ active: route.name === 'ledger' }"><BookOpen :size="20" /><span>流水</span></RouterLink>
<RouterLink to="/stats" :class="{ active: route.name === 'stats' }"><BarChart3 :size="20" /><span>统计</span></RouterLink>
<RouterLink to="/query" :class="{ active: route.name === 'query' }"><Search :size="20" /><span>查询</span></RouterLink>
<span class="nav-spacer"></span>
<RouterLink to="/ledgers" :class="{ active: route.name === 'ledgers' }"><LibraryBig :size="20" /><span>账本</span></RouterLink>
<RouterLink to="/me" :class="{ active: route.name === 'me' }"><CircleUserRound :size="20" /><span>我的</span></RouterLink>
</nav>
<button v-if="!drawerOpen" class="add-entry-button" type="button" aria-label="记一笔" title="记一笔" @click="drawerOpen = true">
<button v-if="!drawerOpen" class="add-entry-button" type="button" :style="{ background: ledgerTheme(ledgerStore.currentLedger?.theme).accent }" aria-label="记一笔" title="记一笔" @click="drawerOpen = true">
<Plus :size="28" />
</button>

View File

@ -0,0 +1,115 @@
import {
Baby,
BookOpen,
CakeSlice,
CarFront,
Gift,
GraduationCap,
Hammer,
Heart,
House,
Luggage,
Map as MapIcon,
Milk,
PaintBucket,
PartyPopper,
Sofa,
ToyBrick,
Utensils,
Users,
WalletCards,
Wrench,
ShoppingBag,
ShoppingCart,
} from "@lucide/vue";
import type { Component } from "vue";
import type { LedgerIconKey } from "@cents/domain";
export type LedgerIconOption = {
key: LedgerIconKey;
label: string;
icon: Component;
};
export type LedgerIconPreset = {
id: string;
name: string;
options: LedgerIconOption[];
};
export const ledgerIconPresets: LedgerIconPreset[] = [
{
id: "daily",
name: "日常",
options: [
{ key: "wallet", label: "钱包", icon: WalletCards },
{ key: "house", label: "家庭", icon: House },
{ key: "shopping-bag", label: "购物", icon: ShoppingBag },
{ key: "shopping-cart", label: "购物车", icon: ShoppingCart },
{ key: "utensils", label: "餐饮", icon: Utensils },
],
},
{
id: "travel",
name: "旅行",
options: [
{ key: "luggage", label: "行李", icon: Luggage },
{ key: "map", label: "地图", icon: MapIcon },
],
},
{
id: "parenting",
name: "育儿",
options: [
{ key: "baby", label: "育儿", icon: Baby },
{ key: "milk", label: "奶瓶", icon: Milk },
{ key: "heart", label: "关爱", icon: Heart },
{ key: "graduation-cap", label: "教育", icon: GraduationCap },
{ key: "toy-brick", label: "玩具", icon: ToyBrick },
],
},
{
id: "gathering",
name: "聚会",
options: [
{ key: "party-popper", label: "庆祝", icon: PartyPopper },
{ key: "cake", label: "蛋糕", icon: CakeSlice },
{ key: "gift", label: "礼物", icon: Gift },
{ key: "users", label: "聚会", icon: Users },
],
},
{
id: "renovation",
name: "装修",
options: [
{ key: "paint-bucket", label: "涂料", icon: PaintBucket },
{ key: "hammer", label: "施工", icon: Hammer },
{ key: "sofa", label: "家具", icon: Sofa },
{ key: "house", label: "家装", icon: House },
],
},
{
id: "learning",
name: "教育",
options: [
{ key: "book-open", label: "书籍", icon: BookOpen },
{ key: "graduation-cap", label: "学业", icon: GraduationCap },
],
},
{
id: "car-care",
name: "养车",
options: [
{ key: "car", label: "汽车", icon: CarFront },
{ key: "wrench", label: "维修", icon: Wrench },
],
},
];
const ledgerIconMap = new Map<string, Component>(
ledgerIconPresets.flatMap((preset) => preset.options.map((option) => [option.key, option.icon] as const)),
);
export function ledgerIconComponent(key?: LedgerIconKey | string) {
return ledgerIconMap.get(key ?? "wallet") ?? ledgerIconMap.get("wallet")!;
}

View File

@ -1,4 +1,6 @@
import { ledgerThemeAccents, type CurrencyCode, type LedgerAmountDisplayMode, type LedgerThemeId } from "@cents/domain";
import { ledgerThemeAccents, type CurrencyCode, type LedgerAmountDisplayMode, type LedgerIconKey, type LedgerThemeId } from "@cents/domain";
export type LedgerSummaryRange = "ledger" | "year";
export const ledgerThemes: ReadonlyArray<{
id: LedgerThemeId;
@ -18,6 +20,12 @@ export const ledgerThemes: ReadonlyArray<{
{ 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%)" },
{ id: "mint", name: "薄荷", accent: ledgerThemeAccents.mint, gradient: "linear-gradient(135deg, #39a889 0%, #3c9b83 52%, #357a91 100%)" },
{ id: "plum", name: "紫藤", accent: ledgerThemeAccents.plum, gradient: "linear-gradient(135deg, #a566a1 0%, #895c9c 52%, #526e9d 100%)" },
{ id: "ember", name: "余烬", accent: ledgerThemeAccents.ember, gradient: "linear-gradient(135deg, #d17b4a 0%, #b86545 52%, #765174 100%)" },
{ id: "aqua", name: "青湾", accent: ledgerThemeAccents.aqua, gradient: "linear-gradient(135deg, #38b0b1 0%, #258fa3 52%, #456ca0 100%)" },
{ id: "rose", name: "玫瑰", accent: ledgerThemeAccents.rose, gradient: "linear-gradient(135deg, #d65b75 0%, #c6536f 52%, #805684 100%)" },
{ id: "forest", name: "深林", accent: ledgerThemeAccents.forest, gradient: "linear-gradient(135deg, #5d9a6d 0%, #4b7f5c 52%, #536b46 100%)" },
];
export function ledgerTheme(themeId?: string) {
@ -29,8 +37,10 @@ export type LedgerRecord = {
name: string;
color: string;
theme: LedgerThemeId;
icon: LedgerIconKey;
defaultCurrency: CurrencyCode;
amountDisplay: LedgerAmountDisplayMode;
summaryRange: LedgerSummaryRange;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
@ -41,7 +51,7 @@ export type LedgerRecord = {
export type UserPreference = {
id: string;
userId: string;
key: "currentLedgerId" | "ledgerAmountDisplay" | "legacyMigration";
key: "currentLedgerId" | "ledgerAmountDisplay" | "ledgerSummaryRange" | "legacyMigration";
value: string;
updatedAt: string;
};
@ -54,6 +64,10 @@ export function ledgerAmountDisplayPreferenceId(userId: string, ledgerId: string
return `${userId}:ledgerAmountDisplay:${ledgerId}`;
}
export function ledgerSummaryRangePreferenceId(userId: string, ledgerId: string) {
return `${userId}:ledgerSummaryRange:${ledgerId}`;
}
export function createDefaultLedgers(): LedgerRecord[] {
const now = new Date().toISOString();
return [
@ -62,8 +76,10 @@ export function createDefaultLedgers(): LedgerRecord[] {
name: "家庭日常",
color: "#087f72",
theme: "jade",
icon: "wallet",
defaultCurrency: "CNY",
amountDisplay: "base",
summaryRange: "year",
createdAt: now,
updatedAt: now,
archivedAt: null,
@ -74,8 +90,10 @@ export function createDefaultLedgers(): LedgerRecord[] {
name: "旅行账本",
color: "#3478e5",
theme: "ocean",
icon: "luggage",
defaultCurrency: "CNY",
amountDisplay: "base",
summaryRange: "year",
createdAt: now,
updatedAt: now,
archivedAt: null,

View File

@ -43,6 +43,11 @@ export const router = createRouter({
name: "stats",
component: () => import("./views/StatsView.vue"),
},
{
path: "/query",
name: "query",
component: () => import("./views/StatsEntriesView.vue"),
},
{
path: "/ledgers",
name: "ledgers",

View File

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { apiRequest } from "../data/api";
import { getUserDb } from "../data/db";
import { currentLedgerPreferenceId, ledgerAmountDisplayPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers";
import { currentLedgerPreferenceId, ledgerAmountDisplayPreferenceId, ledgerSummaryRangePreferenceId, type LedgerRecord, type LedgerSummaryRange, type UserPreference } from "../data/ledgers";
import type { LedgerAmountDisplayMode } from "@cents/domain";
import { useAuthStore } from "./auth";
@ -46,12 +46,21 @@ export const useLedgerStore = defineStore("ledgers", {
.equals(auth.user.id)
.filter((preference) => preference.key === "ledgerAmountDisplay")
.toArray();
const summaryPreferences = await db.userPreferences
.where("userId")
.equals(auth.user.id)
.filter((preference) => preference.key === "ledgerSummaryRange")
.toArray();
const displayByLedger = new Map(
displayPreferences.map((preference) => [preference.id.split(":").at(-1), preference.value as LedgerAmountDisplayMode]),
);
const summaryByLedger = new Map(
summaryPreferences.map((preference) => [preference.id.split(":").at(-1), preference.value as LedgerSummaryRange]),
);
const serverLedgers = result.ledgers.map((ledger) => ({
...ledger,
amountDisplay: displayByLedger.get(ledger.id) ?? "base",
summaryRange: summaryByLedger.get(ledger.id) ?? "year",
}));
this.ledgers = serverLedgers;
@ -115,13 +124,13 @@ export const useLedgerStore = defineStore("ledgers", {
memberName(userId: string) {
return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户";
},
async createLedger(input: Pick<LedgerRecord, "name" | "color" | "theme" | "defaultCurrency">) {
async createLedger(input: Pick<LedgerRecord, "name" | "color" | "theme" | "icon" | "defaultCurrency">) {
const result = await apiRequest<{ ledger: LedgerRecord }>("/api/ledgers", { method: "POST", body: input });
await this.reloadLedgers();
await this.setCurrentLedger(result.ledger.id);
return result.ledger;
},
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "theme" | "defaultCurrency"> & { amountDisplay?: LedgerAmountDisplayMode }) {
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "theme" | "icon" | "defaultCurrency"> & { amountDisplay?: LedgerAmountDisplayMode }) {
const { amountDisplay, ...serverInput } = input;
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: serverInput });
if (amountDisplay) {
@ -141,5 +150,20 @@ export const useLedgerStore = defineStore("ledgers", {
await this.reloadLedgers();
return this.ledgers.find((ledger) => ledger.id === input.id)!;
},
async setSummaryRange(ledgerId: string, summaryRange: LedgerSummaryRange) {
const auth = useAuthStore();
if (!auth.user) return;
const db = await getUserDb(auth.user.id);
const preference: UserPreference = {
id: ledgerSummaryRangePreferenceId(auth.user.id, ledgerId),
userId: auth.user.id,
key: "ledgerSummaryRange",
value: summaryRange,
updatedAt: new Date().toISOString(),
};
await db.userPreferences.put(preference);
const ledger = this.ledgers.find((item) => item.id === ledgerId);
if (ledger) ledger.summaryRange = summaryRange;
},
},
});

View File

@ -73,7 +73,7 @@ input:focus-visible {
.ledger-header {
position: relative;
z-index: 1;
z-index: 5;
min-height: 180px;
padding: max(18px, env(safe-area-inset-top)) 18px 20px;
background: var(--ledger-gradient, #087f72);
@ -92,11 +92,13 @@ input:focus-visible {
.top-actions {
display: grid;
grid-template-columns: 56px 1fr 40px;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 10px;
}
.ledger-header-actions { display:flex; align-items:center; gap:4px; }
.participant-avatars {
width: 56px;
height: 40px;
@ -164,7 +166,7 @@ input:focus-visible {
.ledger-more-layer {
position: absolute;
inset: 0;
z-index: 8;
z-index: 50;
}
.ledger-more-scrim {
@ -180,6 +182,7 @@ input:focus-visible {
position: absolute;
top: 52px;
right: 14px;
z-index: 51;
width: 142px;
overflow: hidden;
border: 1px solid rgba(218, 232, 228, 0.92);
@ -222,6 +225,8 @@ input:focus-visible {
color: inherit;
}
.ledger-header-icon { width:26px; height:26px; display:grid; place-items:center; flex:0 0 auto; border-radius:7px; color:#fff; box-shadow:0 2px 6px rgba(20,45,40,.18); }
.ledger-switcher {
min-width: 0;
font-size: 19px;
@ -229,13 +234,26 @@ input:focus-visible {
}
.monthly-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(132px, .72fr);
align-items: center;
gap: 18px;
display:block;
min-width:0;
margin-top: 22px;
overflow:hidden;
}
.summary-values { display:grid; grid-template-columns:minmax(0,1fr) minmax(132px,.72fr); align-items:center; gap:18px; }
.summary-balance-label { min-width:0; display:flex; align-items:baseline; justify-content:flex-start; gap:8px; }
.summary-balance-label > span { min-width:52px; margin-bottom:0; line-height:1.2; }
.summary-currency-picker { min-width:0; max-width:calc(100% - 60px); display:flex; flex:0 1 auto; gap:3px; overflow-x:auto; scrollbar-width:none; }
.summary-currency-picker::-webkit-scrollbar { display:none; }
.summary-currency-picker button { flex:0 0 auto; border:0; border-radius:5px; padding:2px 6px 1px; background:transparent; color:rgba(255,255,255,.42); font-size:10px; line-height:1.2; font-variant-numeric:tabular-nums; }
.summary-currency-picker button.active { background:rgba(255,255,255,.2); color:#fff; font-weight:720; }
.summary-value-window { min-width:0; overflow:hidden; }
.summary-value-track { display:flex; width:calc(100% * var(--summary-slides)); transition:transform 260ms cubic-bezier(.22,.8,.25,1); }
.summary-value-track.dragging { transition:none; }
.summary-value-slide { flex:0 0 calc(100% / var(--summary-slides)); min-width:0; overflow:hidden; text-align:center; text-overflow:ellipsis; }
.summary-main .summary-value-slide { text-align:left; }
.summary-stat .summary-value-slide { text-align:right; }
.summary-main,
.summary-stat {
min-width: 0;
@ -415,13 +433,16 @@ input:focus-visible {
.month-picker-modal > header { min-height:56px; display:grid; grid-template-columns:38px 1fr 38px; align-items:center; border-bottom:1px solid #e1ebe8; }
.month-picker-modal > header strong { text-align:center; font-size:16px; }
.month-picker-modal > header button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
.month-picker-year { width:100%; min-height:54px; display:flex; align-items:center; justify-content:center; gap:4px; border:0; background:transparent; color:#26342f; }
.month-picker-year strong { font-size:21px; }
.month-picker-year-nav { display:grid; grid-template-columns:38px repeat(3,minmax(0,1fr)) 38px; align-items:center; gap:2px; min-height:54px; }
.month-picker-year-nav > button { min-height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#82918c; font-size:15px; font-variant-numeric:tabular-nums; }
.month-picker-year-nav > button:not(:first-child):not(:last-child).selected { background:#eaf7f3; color:#087f72; font-weight:720; }
.month-picker-year-nav > button:disabled { cursor:default; opacity:.32; }
.month-picker-total { display:flex; justify-content:center; gap:18px; margin:-2px 0 14px; font-size:12px; font-variant-numeric:tabular-nums; }
.month-picker-total .income,.month-picker-grid .income,.year-picker-list .income { color:#0d8b67; }
.month-picker-total .expense,.month-picker-grid .expense,.year-picker-list .expense { color:#d84c36; }
.month-picker-total .income,.month-picker-grid .income { color:#0d8b67; }
.month-picker-total .expense,.month-picker-grid .expense { color:#d84c36; }
.month-picker-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.month-picker-grid button { min-height:68px; display:grid; align-content:center; gap:2px; border:1px solid #e1ebe8; border-radius:8px; padding:7px 5px; background:#fff; text-align:center; }
.month-picker-grid button:disabled { cursor:default; opacity:.42; }
.month-picker-grid button.selected { border-color:#087f72; background:#eaf7f3; box-shadow:inset 0 0 0 1px #087f72; }
.month-picker-grid button strong { color:#26342f; font-size:14px; }
.month-picker-grid button small { overflow:hidden; color:#7a8783; font-size:10px; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; }
@ -469,12 +490,29 @@ input:focus-visible {
color: #d84c36;
}
.list-filter-action { display:flex; align-items:center; gap:3px; flex:0 0 auto; }
.list-action {
width: 36px;
height: 36px;
color: #41605a;
}
.list-action.active { color:#087f72; background:#e5f5f0; }
.active-filter-count { color:#087f72!important; font-size:10px!important; font-weight:720; white-space:nowrap; }
.filter-layer { z-index:17; }
.filter-modal { max-height:88%; }
.filter-keyword-field { display:grid; gap:6px; border-bottom:1px solid #e1ebe8; padding:14px 0; }
.filter-keyword-field > span,.filter-category-heading strong { color:#7b8884; font-size:11px; }
.filter-keyword-field input { width:100%; min-height:40px; border:1px solid #d2dfdc; border-radius:8px; padding:0 10px; outline:0; background:#f8fbfa; color:#26342f; font-size:14px; }
.filter-category-heading { display:flex; align-items:center; justify-content:space-between; padding:14px 0 8px; }
.filter-category-heading button { border:0; padding:3px 0; background:transparent; color:#087f72; font-size:11px; }
.filter-category-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.filter-category-option { min-height:42px; display:flex; align-items:center; justify-content:space-between; gap:5px; border:1px solid #e1ebe8; border-radius:8px; padding:0 9px; background:#fff; color:#344640; text-align:left; }
.filter-category-option.child { min-height:36px; padding-left:7px; color:#60716c; font-size:12px; }
.filter-category-option.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; font-weight:720; }
.filter-category-option small { color:inherit; font-size:10px; font-weight:500; }
.filter-done { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.day-group {
padding-top: 14px;
}
@ -806,7 +844,7 @@ input:focus-visible {
}
.ledger-notice-slot { position:absolute; top:0; right:0; left:0; z-index:4; height:0; overflow:visible; pointer-events:none; }
.conversion-notice { height:32px; display:flex; align-items:center; border-bottom:1px solid #eadfca; padding:4px 12px; background:#fff9ed; color:#775b24; font-size:11px; box-shadow:0 2px 8px rgba(58,43,17,.08); pointer-events:auto; }
.conversion-notice { height:32px; display:flex; align-items:center; justify-content:space-between; gap:8px; border-bottom:1px solid #eadfca; padding:4px 12px; background:#fff9ed; color:#775b24; font-size:11px; box-shadow:0 2px 8px rgba(58,43,17,.08); pointer-events:auto; }
.ledger-pull-space { display:flex; align-items:center; justify-content:center; gap:5px; flex:0 0 auto; color:#7d8c88; font-size:11px; opacity:var(--pull-progress); pointer-events:none; overflow:hidden; }
.ledger-pull-space.top { margin:0 -16px; }
.ledger-pull-space.bottom { margin:0 -16px; }
@ -814,6 +852,9 @@ input:focus-visible {
.ledger-pull-space.previous { color:#b05f22; }
.ledger-pull-space.next { color:#3478e5; }
.conversion-notice span { display:flex; align-items:center; gap:6px; }
.conversion-notice button { display:inline-flex; align-items:center; gap:4px; flex:0 0 auto; min-height:25px; border:0; border-radius:7px; padding:0 8px; background:#f0e2c9; color:#76521d; font-size:11px; font-weight:680; }
.conversion-notice button:disabled { opacity:.6; }
.conversion-notice .spinning { animation: sync-spin .8s linear infinite; }
.amount-value {
overflow: hidden;
@ -1516,8 +1557,7 @@ input:focus-visible {
}
.monthly-summary {
grid-template-columns: minmax(0, 1fr) minmax(126px, .72fr);
gap: 10px;
display:block;
}
.summary-main strong {

View File

@ -211,14 +211,14 @@ async function deleteEntry() {
const message = `确定从这笔账目关联的全部账本中删除吗?\n\n共 ${count} 个账本,删除后无法恢复。`;
if (!window.confirm(message)) return;
await store.deleteEntry(entry.value.id);
await router.replace("/");
await router.back();
}
</script>
<template>
<main class="app-shell entry-detail-shell">
<header class="entry-detail-appbar">
<button class="entry-detail-icon-button" type="button" aria-label="返回流水" title="返回" @click="router.push('/')">
<button class="entry-detail-icon-button" type="button" aria-label="返回" title="返回" @click="router.back()">
<ArrowLeft :size="22" />
</button>
<strong>条目明细</strong>
@ -228,7 +228,7 @@ async function deleteEntry() {
<div v-if="loading" class="entry-detail-state">正在载入...</div>
<div v-else-if="!entry" class="entry-detail-state">
<strong>条目不存在</strong>
<button type="button" @click="router.replace('/')">返回流水</button>
<button type="button" @click="router.back()">返回</button>
</div>
<form v-else class="entry-detail-form" @submit.prevent="saveEntry">

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import type { CurrencyCode, LedgerAmountDisplayMode, LedgerThemeId } from "@cents/domain";
import type { CurrencyCode, LedgerAmountDisplayMode, LedgerIconKey, LedgerThemeId } from "@cents/domain";
import { ArrowLeft, Check } from "@lucide/vue";
import { onMounted, ref } from "vue";
import { onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
import LedgerIconPicker from "../components/LedgerIconPicker.vue";
import { ledgerTheme, ledgerThemes, type LedgerSummaryRange } from "../data/ledgers";
import { useLedgerStore } from "../stores/ledgers";
const router = useRouter();
@ -12,6 +13,8 @@ const name = ref("");
const themeId = ref<LedgerThemeId>("jade");
const currency = ref<CurrencyCode>("CNY");
const amountDisplay = ref<LedgerAmountDisplayMode>("base");
const iconKey = ref<LedgerIconKey>("wallet");
const summaryRange = ref<LedgerSummaryRange>("year");
const saving = ref(false);
onMounted(async () => {
@ -25,13 +28,19 @@ onMounted(async () => {
themeId.value = ledgerStore.currentLedger.theme;
currency.value = ledgerStore.currentLedger.defaultCurrency;
amountDisplay.value = ledgerStore.currentLedger.amountDisplay;
iconKey.value = ledgerStore.currentLedger.icon;
summaryRange.value = ledgerStore.currentLedger.summaryRange;
});
watch(summaryRange, (value) => {
if (ledgerStore.currentLedgerId) void ledgerStore.setSummaryRange(ledgerStore.currentLedgerId, value);
});
async function save() {
if (!ledgerStore.currentLedger || !name.value.trim()) return;
saving.value = true;
const theme = ledgerTheme(themeId.value);
await ledgerStore.updateLedger({ id: ledgerStore.currentLedger.id, name: name.value, color: theme.accent, theme: theme.id, defaultCurrency: currency.value, amountDisplay: amountDisplay.value });
await ledgerStore.updateLedger({ id: ledgerStore.currentLedger.id, name: name.value, color: theme.accent, theme: theme.id, icon: iconKey.value, defaultCurrency: currency.value, amountDisplay: amountDisplay.value });
saving.value = false;
await router.back();
}
@ -47,13 +56,18 @@ async function save() {
<section>
<label><span>账本名称</span><input v-model="name" maxlength="24" :disabled="ledgerStore.currentLedger?.isPersonal" required /></label>
<label><span>默认币种 · 暂不可修改</span><select v-model="currency" disabled title="默认币种暂不可修改"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="THB">泰铢 THB</option><option value="HKD">港币 HKD</option></select></label>
<label><span>流水金额显示</span><select v-model="amountDisplay"><option value="base">本币金额</option><option value="original">原币金额</option></select></label>
<label><span>流水金额显示</span><select v-model="amountDisplay"><option value="base">本币金额人民币</option><option value="original">原币金额</option></select></label>
<label><span>流水页头统计范围</span><select v-model="summaryRange"><option value="year">当年累计</option><option value="ledger">账本累计</option></select></label>
</section>
<section class="color-setting">
<span>账本主题</span>
<div>
<button v-for="theme in ledgerThemes" :key="theme.id" type="button" :class="{ active: themeId === theme.id }" :style="{ background: theme.gradient }" :aria-label="`选择${theme.name}主题`" :title="theme.name" @click="themeId = theme.id"><Check v-if="themeId === theme.id" :size="17" /></button>
</div>
</div>
</section>
<section class="icon-setting">
<span>账本图标</span>
<LedgerIconPicker v-model="iconKey" />
</section>
<button class="save-ledger-settings" type="submit" :disabled="saving || !name.trim()"><Check :size="19" />{{ saving ? "保存中" : "保存设置" }}</button>
</form>
@ -72,8 +86,10 @@ async function save() {
.settings-form label span,.color-setting > span { color:#7b8884; font-size:11px; }
.settings-form input,.settings-form select { width:100%; border:0; padding:0; outline:0; background:transparent; color:#26342f; font-size:15px; }
.color-setting { margin-top:12px; padding-top:14px!important; padding-bottom:16px!important; }
.color-setting > div { display:grid; grid-template-columns:repeat(6, 38px); justify-content:space-between; gap:4px; margin-top:12px; }
.color-setting button { width:38px; height:34px; display:grid; place-items:center; border:3px solid #fff; border-radius:8px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.icon-setting { margin-top:12px; padding:14px!important; }
.icon-setting > span { display:block; margin-bottom:12px; color:#7b8884; font-size:11px; }
.color-setting > div { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; width:100%; margin-top:12px; }
.color-setting button { width:100%; aspect-ratio:1; display:grid; place-items:center; border:3px solid #fff; border-radius:9px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.color-setting button.active { box-shadow:0 0 0 2px #263b36; }
.save-ledger-settings { position:absolute; right:16px; bottom:16px; left:16px; height:48px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.save-ledger-settings:disabled { opacity:.55; }

View File

@ -7,22 +7,24 @@ import {
ChevronRight,
ChevronUp,
CircleUserRound,
BarChart3,
CloudOff,
Copy,
LoaderCircle,
Clock3,
Filter,
MoreHorizontal,
RefreshCw,
Search,
Settings,
Share2,
X,
} from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { apiRequest, ApiError } from "../data/api";
import { entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
@ -30,6 +32,7 @@ import { useLedgerStore } from "../stores/ledgers";
const ENTRY_PAGE_SIZE = 50;
const store = useEntryStore();
const ledgerStore = useLedgerStore();
const route = useRoute();
const router = useRouter();
const moreMenuOpen = ref(false);
const showBaseCurrency = computed(() => ledgerStore.currentLedger?.amountDisplay !== "original");
@ -38,10 +41,11 @@ const shareOpen = ref(false);
const shareFeedback = ref("");
const invitationLink = ref("");
const generatingInvitation = ref(false);
const converting = ref(false);
const visibleCount = ref(ENTRY_PAGE_SIZE);
const ledgerContent = ref<HTMLElement | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const selectedMonth = ref(toMonthValue(new Date()));
const selectedMonth = ref(monthQueryValue(route.query.month) ?? toMonthValue(new Date()));
const monthPickerOpen = ref(false);
const monthPickerMode = ref<"months" | "years">("months");
const monthPickerYear = ref(new Date().getFullYear());
@ -52,14 +56,62 @@ const pullStartY = ref<number | null>(null);
const pullDistance = ref(0);
const pullEdge = ref<"top" | "bottom" | null>(null);
const pullLoading = ref(false);
const summaryCurrencyIndex = ref(0);
const filterOpen = ref(false);
const filterKeyword = ref(queryString(route.query.q));
const selectedFilterCategories = ref(parseFilterCategories(route.query.cat));
let loadMoreObserver: IntersectionObserver | null = null;
type FilterCategoryOption = {
key: string;
type: LedgerEntry["type"];
label: string;
parentLabel?: string;
};
function queryString(value: unknown) {
return typeof value === "string" ? value : "";
}
function parseFilterCategories(value: unknown) {
return [...new Set(queryString(value).split(",").filter(Boolean))];
}
const filterCategoryOptions = computed<FilterCategoryOption[]>(() =>
entryTypes.flatMap((type) => [
{ key: `${type.id}:*`, type: type.id, label: type.label },
...categories[type.id].flatMap((parent) => [
{ key: `${type.id}:${parent.id}`, type: type.id, label: parent.label },
...(parent.children ?? []).map((child) => ({ key: `${type.id}:${child.id}`, type: type.id, label: child.label, parentLabel: parent.label })),
]),
]),
);
function isFilterCategorySelected(key: string) {
return selectedFilterCategories.value.includes(key);
}
function toggleFilterCategory(key: string) {
selectedFilterCategories.value = isFilterCategorySelected(key)
? selectedFilterCategories.value.filter((item) => item !== key)
: [...selectedFilterCategories.value, key];
}
function matchesFilterCategory(entry: LedgerEntry, key: string) {
const separator = key.indexOf(":");
const type = key.slice(0, separator);
const categoryId = key.slice(separator + 1);
if (entry.type !== type) return false;
if (categoryId === "*") return true;
return findCategoryPath(entry.type, entry.categoryId).some((category) => category.id === categoryId);
}
const selectedMonthParts = computed(() => {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
if (!match) return null;
return { year: Number(match[1]), month: Number(match[2]) - 1 };
});
const monthlyEntries = computed(() => {
const monthEntries = computed(() => {
const selected = selectedMonthParts.value;
if (!selected) return [];
return store.entries.filter((entry) => {
@ -70,26 +122,28 @@ const monthlyEntries = computed(() => {
});
});
const totals = computed(() => {
return monthlyEntries.value.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 },
);
const monthlyEntries = computed(() => {
const keyword = filterKeyword.value.trim().toLocaleLowerCase();
const categoryKeys = selectedFilterCategories.value;
if (!keyword && !categoryKeys.length) return monthEntries.value;
return monthEntries.value.filter((entry) => {
const matchesKeyword = !keyword || entry.note.toLocaleLowerCase().includes(keyword);
const matchesCategory = !categoryKeys.length || categoryKeys.some((key) => matchesFilterCategory(entry, key));
return matchesKeyword && matchesCategory;
});
});
const balanceText = computed(() => formatMoney(totals.value.income - totals.value.expense));
const incomeText = computed(() => formatMoney(totals.value.income));
const expenseText = computed(() => formatMoney(totals.value.expense));
const activeFilterCount = computed(() => selectedFilterCategories.value.length + (filterKeyword.value.trim() ? 1 : 0));
const totals = computed(() => summarizeEntries(monthlyEntries.value));
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
const pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
const pendingConversionCount = computed(() =>
monthlyEntries.value.filter((entry) => entry.conversionStatus === "pending").length,
showBaseCurrency.value
? monthlyEntries.value.filter((entry) => entry.currency !== "CNY" && entry.baseAmount === null).length
: 0,
);
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
@ -124,21 +178,75 @@ const groupedEntries = computed(() => {
const ledgerEntries = computed(() => store.entries.filter((entry) => entry.ledgerIds.includes(ledgerStore.currentLedgerId)));
const headerEntries = computed(() => {
if (ledgerStore.currentLedger?.summaryRange === "ledger") return ledgerEntries.value;
const selected = selectedMonthParts.value;
if (!selected) return [];
return ledgerEntries.value.filter((entry) => new Date(entry.occurredAt).getFullYear() === selected.year);
});
const headerSummaryLabel = computed(() => ledgerStore.currentLedger?.summaryRange === "ledger" ? "账本结余" : "当年结余");
const summaryCurrencySlides = computed(() => {
const summaries = new Map<LedgerEntry["currency"], { income: number; expense: number }>();
for (const entry of headerEntries.value) {
const currency = showBaseCurrency.value ? "CNY" : entry.currency;
const amount = showBaseCurrency.value ? entry.baseAmount : entry.amount;
if (amount === null) continue;
const summary = summaries.get(currency) ?? { income: 0, expense: 0 };
if (entry.type === "income") summary.income += amount;
else summary.expense += amount;
summaries.set(currency, summary);
}
if (!summaries.size) summaries.set("CNY", { income: 0, expense: 0 });
return [...summaries.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([currency, summary]) => ({
currency,
balance: formatCurrencyAmount(summary.income - summary.expense, currency),
income: formatCurrencyAmount(summary.income, currency),
expense: formatCurrencyAmount(summary.expense, currency),
}));
});
watch(summaryCurrencySlides, (slides) => {
summaryCurrencyIndex.value = Math.min(summaryCurrencyIndex.value, Math.max(0, slides.length - 1));
});
function summarizeEntries(entries: LedgerEntry[]) {
return entries.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 },
);
let income = 0;
let expense = 0;
const incomeByCurrency = new Map<LedgerEntry["currency"], number>();
const expenseByCurrency = new Map<LedgerEntry["currency"], number>();
const balanceByCurrency = new Map<LedgerEntry["currency"], number>();
for (const entry of entries) {
if (showBaseCurrency.value) {
if (entry.baseAmount === null) continue;
if (entry.type === "income") income += entry.baseAmount;
else expense += entry.baseAmount;
continue;
}
const totalsByCurrency = entry.type === "income" ? incomeByCurrency : expenseByCurrency;
totalsByCurrency.set(entry.currency, (totalsByCurrency.get(entry.currency) ?? 0) + entry.amount);
balanceByCurrency.set(entry.currency, (balanceByCurrency.get(entry.currency) ?? 0) + (entry.type === "income" ? entry.amount : -entry.amount));
}
return {
income,
expense,
count: entries.length,
incomeParts: showBaseCurrency.value ? [`¥${formatMoney(income)}`] : formatCurrencyParts(incomeByCurrency),
expenseParts: showBaseCurrency.value ? [`¥${formatMoney(expense)}`] : formatCurrencyParts(expenseByCurrency),
balanceParts: showBaseCurrency.value ? [`¥${formatMoney(income - expense)}`] : formatCurrencyParts(balanceByCurrency),
incomeLabel: showBaseCurrency.value ? `¥${formatMoney(income)}` : formatCurrencySummary(incomeByCurrency),
expenseLabel: showBaseCurrency.value ? `¥${formatMoney(expense)}` : formatCurrencySummary(expenseByCurrency),
balanceLabel: showBaseCurrency.value ? `¥${formatMoney(income - expense)}` : formatCurrencySummary(balanceByCurrency),
};
}
const availableYears = computed(() => {
const years = new Set<number>([new Date().getFullYear(), ...ledgerEntries.value.map((entry) => new Date(entry.occurredAt).getFullYear())]);
return [...years].sort((left, right) => right - left);
const currentYear = new Date().getFullYear();
const entryYears = ledgerEntries.value.map((entry) => new Date(entry.occurredAt).getFullYear());
const firstYear = Math.min(currentYear, ...entryYears);
return Array.from({ length: currentYear - firstYear + 1 }, (_, index) => currentYear - index);
});
const yearSummaries = computed(() => availableYears.value.map((year) => ({
@ -154,10 +262,34 @@ const monthSummaries = computed(() => Array.from({ length: 12 }, (_, month) => (
})),
})));
const pickerYearChoices = computed(() => [
monthPickerYear.value - 1,
monthPickerYear.value,
monthPickerYear.value + 1,
].map((year) => yearSummaries.value.find((summary) => summary.year === year) ?? {
year,
income: 0,
expense: 0,
count: 0,
incomeParts: ["0"],
expenseParts: ["0"],
balanceParts: ["0"],
incomeLabel: "0",
expenseLabel: "0",
balanceLabel: "0",
}));
const pickerYearSummary = computed(() => yearSummaries.value.find((summary) => summary.year === monthPickerYear.value) ?? {
year: monthPickerYear.value,
income: 0,
expense: 0,
count: 0,
incomeParts: ["0"],
expenseParts: ["0"],
balanceParts: ["0"],
incomeLabel: "0",
expenseLabel: "0",
balanceLabel: "0",
});
const monthTitle = computed(() => {
@ -165,10 +297,40 @@ const monthTitle = computed(() => {
return selected ? `${selected.year}${selected.month + 1}` : "选择月份";
});
watch([() => ledgerStore.currentLedgerId, ledgerEntries], ([ledgerId, entries]) => {
if (!ledgerId || !entries.length || monthEntries.value.length) return;
const latestEntry = [...entries].sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0];
if (latestEntry) selectedMonth.value = toMonthValue(new Date(latestEntry.occurredAt));
}, { immediate: true });
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
});
watch(selectedMonth, (value) => {
if (route.name !== "ledger" || monthQueryValue(route.query.month) === value) return;
void router.replace({ query: { ...route.query, month: value } });
});
watch(() => route.query, () => {
filterKeyword.value = queryString(route.query.q);
selectedFilterCategories.value = parseFilterCategories(route.query.cat);
}, { deep: true });
watch([filterKeyword, selectedFilterCategories], () => {
if (route.name !== "ledger") return;
const query = { ...route.query };
const keyword = filterKeyword.value.trim();
if (keyword) query.q = keyword;
else delete query.q;
if (selectedFilterCategories.value.length) query.cat = selectedFilterCategories.value.join(",");
else delete query.cat;
const currentKeyword = queryString(route.query.q);
const currentCategories = parseFilterCategories(route.query.cat).join(",");
if (currentKeyword === keyword && currentCategories === selectedFilterCategories.value.join(",")) return;
void router.replace({ query });
});
watch(monthlyEntries, () => {
visibleCount.value = ENTRY_PAGE_SIZE;
});
@ -191,17 +353,21 @@ watch([ledgerContent, loadMoreTrigger], ([root, trigger]) => {
onBeforeUnmount(() => loadMoreObserver?.disconnect());
const pullAction = computed(() => {
if (pullEdge.value === "bottom") return pullDistance.value >= 72 ? "next" : null;
if (pullEdge.value === "bottom") {
if (pullDistance.value < 72) return null;
return adjacentMonthValue(1) ? "next" : "end";
}
if (pullEdge.value !== "top") return null;
if (pullDistance.value >= 88) return "previous";
if (pullDistance.value >= 88) return adjacentMonthValue(-1) ? "previous" : "end";
if (pullDistance.value >= 44) return "refresh";
return null;
});
const pullLabel = computed(() => {
if (pullAction.value === "previous") return "加载上一月流水";
if (pullAction.value === "previous") return `加载 ${formatMonthLabel(adjacentMonthValue(-1))} 流水`;
if (pullAction.value === "refresh") return "刷新流水";
if (pullEdge.value === "bottom") return "继续上拉加载下一月流水";
if (pullAction.value === "end") return "没有更多数据";
if (pullEdge.value === "bottom") return `继续上拉加载 ${formatMonthLabel(adjacentMonthValue(1))} 流水`;
return "继续下拉刷新流水";
});
@ -219,6 +385,10 @@ function toMonthValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
function monthQueryValue(value: unknown) {
return typeof value === "string" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : null;
}
function openMonthPicker() {
monthPickerYear.value = selectedMonthParts.value?.year ?? new Date().getFullYear();
monthPickerMode.value = "months";
@ -230,15 +400,45 @@ function closeMonthPicker() {
}
function selectPickerYear(year: number) {
if (!yearSummaries.value.find((summary) => summary.year === year)?.count) return;
monthPickerYear.value = year;
monthPickerMode.value = "months";
}
function openYearPicker() {
monthPickerMode.value = "years";
}
function selectPickerMonth(month: number) {
if (!monthSummaries.value[month]?.count) return;
selectedMonth.value = `${monthPickerYear.value}-${String(month + 1).padStart(2, "0")}`;
closeMonthPicker();
}
async function convertPendingEntries() {
if (converting.value) return;
const entryIds = monthlyEntries.value
.filter((entry) => entry.currency !== "CNY" && entry.baseAmount === null)
.map((entry) => entry.id);
if (!entryIds.length) return;
converting.value = true;
try {
await Promise.all(entryIds.map((entryId) => store.resolveLocalConversion(entryId)));
} finally {
converting.value = false;
}
}
watch([showBaseCurrency, monthlyEntries], ([show]) => {
if (show && pendingConversionCount.value) void convertPendingEntries();
});
function shiftPickerYear(offset: number) {
const target = monthPickerYear.value + offset;
if (!yearSummaries.value.find((summary) => summary.year === target)?.count) return;
monthPickerYear.value = target;
}
function startMonthSwipe(event: TouchEvent) {
const touch = event.touches[0];
if (touch) {
@ -355,10 +555,29 @@ function cancelLedgerPull() {
pullDistance.value = 0;
}
function shiftMonth(offset: number) {
function shiftMonth(offset: -1 | 1) {
const target = adjacentMonthValue(offset);
if (target) selectedMonth.value = target;
}
function adjacentMonthValue(offset: -1 | 1) {
const selected = selectedMonthParts.value;
if (!selected) return;
selectedMonth.value = toMonthValue(new Date(selected.year, selected.month + offset, 1));
if (!selected) return null;
const current = selected.year * 12 + selected.month;
const months = [...new Set(ledgerEntries.value.map((entry) => {
const date = new Date(entry.occurredAt);
return date.getFullYear() * 12 + date.getMonth();
}))];
const candidates = months.filter((month) => offset < 0 ? month < current : month > current);
if (!candidates.length) return null;
const target = offset < 0 ? Math.max(...candidates) : Math.min(...candidates);
return toMonthValue(new Date(Math.floor(target / 12), target % 12, 1));
}
function formatMonthLabel(value: string | null) {
if (!value) return "下一月";
const match = /^(\d{4})-(\d{2})$/.exec(value);
return match ? `${match[1]}${Number(match[2])}` : "下一月";
}
const monthSwipePreview = computed(() => {
@ -396,6 +615,21 @@ function formatCompactMoney(value: number) {
return amount.toLocaleString("zh-CN", { maximumFractionDigits: 0 });
}
function formatCurrencySummary(values: Map<LedgerEntry["currency"], number>) {
return formatCurrencyParts(values).join(" · ");
}
function formatCurrencyParts(values: Map<LedgerEntry["currency"], number>) {
if (!values.size) return ["0"];
return [...values.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, amount]) => formatCurrencyAmount(amount, currency))
}
function selectSummaryCurrency(index: number) {
summaryCurrencyIndex.value = index;
}
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
@ -426,6 +660,11 @@ function openLedgerSettings() {
router.push("/ledger/settings");
}
function openLedgerStats() {
moreMenuOpen.value = false;
void router.push({ name: "stats", query: { range: "month", month: selectedMonth.value } });
}
async function ensureInvitation() {
if (invitationLink.value || generatingInvitation.value || !ledgerStore.currentLedgerId) return invitationLink.value;
generatingInvitation.value = true;
@ -493,37 +732,61 @@ async function shareLedger() {
</button>
<span v-else aria-hidden="true"></span>
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
<span class="ledger-header-icon" :style="{ background: currentTheme.gradient }"><component :is="ledgerIconComponent(ledgerStore.currentLedger?.icon)" :size="16" /></span>
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
<ChevronDown :size="16" />
</button>
<button v-if="ledgerStore.currentRole === 'owner'" class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true">
<MoreHorizontal :size="21" />
</button>
<div class="ledger-header-actions">
<button class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true"><MoreHorizontal :size="21" /></button>
</div>
</div>
<Transition name="more-menu">
<div v-if="moreMenuOpen" class="ledger-more-layer">
<button class="ledger-more-scrim" type="button" aria-label="关闭更多菜单" @click="moreMenuOpen = false"></button>
<div class="ledger-more-menu" role="menu">
<button type="button" role="menuitem" @click="openLedgerStats"><BarChart3 :size="18" />统计</button>
<button type="button" role="menuitem" @click="openLedgerSettings"><Settings :size="18" />设置</button>
<button v-if="!ledgerStore.currentLedger?.isPersonal" type="button" role="menuitem" @click="openShareSheet"><Share2 :size="18" />分享</button>
</div>
</div>
</Transition>
<section class="monthly-summary" aria-label="月度收支">
<div class="summary-main">
<span>结余</span>
<strong :class="{ compact: balanceText.length > 12 }">¥ {{ balanceText }}</strong>
</div>
<div class="summary-secondary">
<div class="summary-stat income-stat">
<span>收入</span>
<strong :class="{ compact: incomeText.length > 10 }">{{ incomeText }}</strong>
<section
class="monthly-summary"
aria-label="月度收支"
>
<div class="summary-values">
<div class="summary-main">
<div class="summary-balance-label">
<span>{{ headerSummaryLabel }}</span>
<div class="summary-currency-picker" role="tablist" aria-label="选择币种">
<button v-for="(slide, index) in summaryCurrencySlides" :key="slide.currency" type="button" role="tab" :aria-selected="summaryCurrencyIndex === index" :class="{ active: summaryCurrencyIndex === index }" @click="selectSummaryCurrency(index)">{{ slide.currency }}</button>
</div>
</div>
<div class="summary-value-window">
<div class="summary-value-track" :style="{ '--summary-slides': summaryCurrencySlides.length, transform: `translate3d(-${summaryCurrencyIndex * (100 / summaryCurrencySlides.length)}%, 0, 0)` }">
<strong v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide" :class="{ compact: slide.balance.length > 12 }">{{ slide.balance }}</strong>
</div>
</div>
</div>
<div class="summary-stat expense-stat">
<span>支出</span>
<strong :class="{ compact: expenseText.length > 10 }">{{ expenseText }}</strong>
<div class="summary-secondary">
<div class="summary-stat income-stat">
<span>收入</span>
<div class="summary-value-window">
<div class="summary-value-track" :style="{ '--summary-slides': summaryCurrencySlides.length, transform: `translate3d(-${summaryCurrencyIndex * (100 / summaryCurrencySlides.length)}%, 0, 0)` }">
<strong v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide" :class="{ compact: slide.income.length > 10 }">{{ slide.income }}</strong>
</div>
</div>
</div>
<div class="summary-stat expense-stat">
<span>支出</span>
<div class="summary-value-window">
<div class="summary-value-track" :style="{ '--summary-slides': summaryCurrencySlides.length, transform: `translate3d(-${summaryCurrencyIndex * (100 / summaryCurrencySlides.length)}%, 0, 0)` }">
<strong v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide" :class="{ compact: slide.expense.length > 10 }">{{ slide.expense }}</strong>
</div>
</div>
</div>
</div>
</div>
</section>
@ -541,33 +804,59 @@ async function shareLedger() {
</header>
<template v-if="monthPickerMode === 'months'">
<button class="month-picker-year" type="button" @click="monthPickerMode = 'years'">
<strong>{{ monthPickerYear }}</strong>
<ChevronDown :size="16" />
</button>
<div class="month-picker-year-nav">
<button type="button" aria-label="上一年" title="上一年" :disabled="!yearSummaries.find((summary) => summary.year === monthPickerYear - 1)?.count" @click="shiftPickerYear(-1)"><ChevronLeft :size="18" /></button>
<button v-for="summary in pickerYearChoices" :key="summary.year" type="button" :disabled="summary.count === 0" :class="{ selected: summary.year === monthPickerYear }" @click="summary.year === monthPickerYear ? openYearPicker() : selectPickerYear(summary.year)">{{ summary.year }}</button>
<button type="button" aria-label="下一年" title="下一年" :disabled="!yearSummaries.find((summary) => summary.year === monthPickerYear + 1)?.count" @click="shiftPickerYear(1)"><ChevronRight :size="18" /></button>
</div>
<div class="month-picker-total">
<span class="income">+ ¥{{ formatMoney(pickerYearSummary.income) }}</span>
<span class="expense"> ¥{{ formatMoney(pickerYearSummary.expense) }}</span>
<span class="income">+ {{ pickerYearSummary.incomeLabel }}</span>
<span class="expense"> {{ pickerYearSummary.expenseLabel }}</span>
</div>
<div class="month-picker-grid">
<button v-for="summary in monthSummaries" :key="summary.month" type="button" :class="{ selected: selectedMonth === `${monthPickerYear}-${String(summary.month + 1).padStart(2, '0')}` }" @click="selectPickerMonth(summary.month)">
<button v-for="summary in monthSummaries" :key="summary.month" type="button" :disabled="summary.count === 0" :class="{ selected: selectedMonth === `${monthPickerYear}-${String(summary.month + 1).padStart(2, '0')}` }" @click="selectPickerMonth(summary.month)">
<strong>{{ summary.month + 1 }}</strong>
<small class="income">+{{ formatCompactMoney(summary.income) }}</small>
<small class="expense">{{ formatCompactMoney(summary.expense) }}</small>
<small class="income">+{{ summary.incomeLabel }}</small>
<small class="expense">{{ summary.expenseLabel }}</small>
</button>
</div>
</template>
<div v-else class="year-picker-list">
<button v-for="summary in yearSummaries" :key="summary.year" type="button" :class="{ selected: summary.year === monthPickerYear }" @click="selectPickerYear(summary.year)">
<button v-for="summary in yearSummaries" :key="summary.year" type="button" :disabled="summary.count === 0" :class="{ selected: summary.year === monthPickerYear }" @click="selectPickerYear(summary.year)">
<strong>{{ summary.year }}</strong>
<span><i class="income">+{{ formatCompactMoney(summary.income) }}</i><i class="expense">{{ formatCompactMoney(summary.expense) }}</i></span>
<span><i class="income">+{{ summary.incomeLabel }}</i><i class="expense">{{ summary.expenseLabel }}</i></span>
</button>
</div>
</section>
</div>
</Transition>
<Transition name="month-picker">
<div v-if="filterOpen" class="month-picker-layer filter-layer">
<button class="month-picker-scrim" type="button" aria-label="关闭筛选" @click="filterOpen = false"></button>
<section class="month-picker-modal filter-modal" role="dialog" aria-modal="true" aria-label="筛选流水">
<header>
<span></span>
<strong>筛选流水</strong>
<button type="button" aria-label="关闭" title="关闭" @click="filterOpen = false"><X :size="20" /></button>
</header>
<label class="filter-keyword-field">
<span>备注关键词</span>
<input v-model="filterKeyword" type="search" placeholder="模糊匹配备注" autocomplete="off" />
</label>
<div class="filter-category-heading"><strong>分类</strong><button v-if="selectedFilterCategories.length" type="button" @click="selectedFilterCategories = []">清除分类</button></div>
<div class="filter-category-list">
<button v-for="option in filterCategoryOptions" :key="option.key" type="button" class="filter-category-option" :class="{ selected: isFilterCategorySelected(option.key), child: option.parentLabel }" @click="toggleFilterCategory(option.key)">
<span><small v-if="option.parentLabel">{{ option.parentLabel }} · </small>{{ option.label }}</span>
<Check v-if="isFilterCategorySelected(option.key)" :size="16" />
</button>
</div>
<button class="filter-done" type="button" @click="filterOpen = false">完成</button>
</section>
</div>
</Transition>
<section
ref="ledgerContent"
class="ledger-content"
@ -586,6 +875,9 @@ async function shareLedger() {
</div>
<div v-else-if="pendingConversionCount" class="conversion-notice" role="status">
<span><Clock3 :size="15" />{{ pendingConversionCount }} 条外币账目等待换算</span>
<button type="button" :disabled="converting" @click="convertPendingEntries">
<RefreshCw :size="14" :class="{ spinning: converting }" />{{ converting ? "换算中" : "立即换算" }}
</button>
</div>
</div>
<div class="list-toolbar" @touchstart.passive="startMonthSwipe" @touchmove.passive="moveMonthSwipe" @touchend="finishMonthSwipe" @touchcancel="cancelMonthSwipe">
@ -603,9 +895,12 @@ async function shareLedger() {
<span class="expense">支出 {{ formatMoney(totals.expense) }}</span>
</div>
</div>
<button class="icon-button list-action" type="button" aria-label="搜索流水" title="搜索流水">
<Search :size="19" />
</button>
<div class="list-filter-action">
<button class="icon-button list-action" type="button" :class="{ active: activeFilterCount }" aria-label="筛选流水" title="筛选流水" @click="filterOpen = true">
<Filter :size="19" />
</button>
<span v-if="activeFilterCount" class="active-filter-count">{{ activeFilterCount }} 项筛选</span>
</div>
</div>
<div v-if="monthSwipeDragging" class="month-swipe-hint" :class="{ next: monthSwipeDelta < 0 }" aria-hidden="true">
<ChevronRight v-if="monthSwipeDelta < 0" :size="14" />

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import type { CurrencyCode, LedgerThemeId } from "@cents/domain";
import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue";
import type { CurrencyCode, LedgerIconKey, LedgerThemeId } from "@cents/domain";
import { BarChart3, Check, ChevronRight, Plus, X } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import LedgerIconPicker from "../components/LedgerIconPicker.vue";
import { ApiError } from "../data/api";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
@ -16,10 +18,15 @@ const ledgerStore = useLedgerStore();
const createOpen = ref(false);
const name = ref("");
const themeId = ref<LedgerThemeId>("jade");
const iconKey = ref<LedgerIconKey>("wallet");
const currency = ref<CurrencyCode>("CNY");
const saving = ref(false);
const errorMessage = ref("");
function toMonthValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
const entryCounts = computed(() => {
const counts = new Map<string, number>();
for (const entry of entryStore.entries) {
@ -34,13 +41,20 @@ onMounted(async () => {
async function selectLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
const returnTo = route.query.returnTo === "/stats" ? "/stats" : "/";
const requestedReturnTo = typeof route.query.returnTo === "string" ? route.query.returnTo : "";
const returnTo = requestedReturnTo === "/stats" || requestedReturnTo.startsWith("/stats?") ? requestedReturnTo : "/";
await router.push(returnTo);
}
async function openLedgerStats(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
await router.push({ name: "stats", query: { range: "month", month: toMonthValue(new Date()), ledgers: ledgerId } });
}
function openCreate() {
name.value = "";
themeId.value = ledgerThemes[ledgerStore.ledgers.length % ledgerThemes.length].id;
iconKey.value = "wallet";
currency.value = ledgerStore.currentLedger?.defaultCurrency ?? "CNY";
errorMessage.value = "";
createOpen.value = true;
@ -57,7 +71,7 @@ async function createLedger() {
errorMessage.value = "";
try {
const theme = ledgerTheme(themeId.value);
await ledgerStore.createLedger({ name: trimmedName, color: theme.accent, theme: theme.id, defaultCurrency: currency.value });
await ledgerStore.createLedger({ name: trimmedName, color: theme.accent, theme: theme.id, icon: iconKey.value, defaultCurrency: currency.value });
createOpen.value = false;
await router.push("/");
} catch (error) {
@ -78,12 +92,15 @@ async function createLedger() {
<div class="ledgers-scroll">
<p class="ledgers-caption">当前账本决定流水统计和快速记账的默认归属</p>
<section class="ledger-list" aria-label="全部账本">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)">
<span class="ledger-list-icon" :style="{ background: ledgerTheme(ledger.theme).gradient }"><LibraryBig :size="22" /></span>
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.isPersonal ? "仅自己可见" : ledger.defaultCurrency }}</small></span>
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
<ChevronRight v-else :size="19" />
</button>
<div v-for="ledger in ledgerStore.ledgers" :key="ledger.id" class="ledger-list-row">
<button type="button" class="ledger-list-select" @click="selectLedger(ledger.id)">
<span class="ledger-list-icon" :style="{ background: ledgerTheme(ledger.theme).gradient }"><component :is="ledgerIconComponent(ledger.icon)" :size="22" /></span>
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.isPersonal ? "仅自己可见" : ledger.defaultCurrency }}</small></span>
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
<ChevronRight v-else :size="19" />
</button>
<button class="ledger-list-stats" type="button" aria-label="查看统计" title="查看统计" @click="openLedgerStats(ledger.id)"><BarChart3 :size="18" /></button>
</div>
</section>
</div>
<QuickEntryHost />
@ -103,6 +120,10 @@ async function createLedger() {
<legend>账本主题</legend>
<div><button v-for="theme in ledgerThemes" :key="theme.id" type="button" :class="{ active: themeId === theme.id }" :style="{ background: theme.gradient }" :aria-label="`选择${theme.name}主题`" :title="theme.name" @click="themeId = theme.id"><Check v-if="themeId === theme.id" :size="17" /></button></div>
</fieldset>
<fieldset class="create-ledger-icons">
<legend>账本图标</legend>
<LedgerIconPicker v-model="iconKey" />
</fieldset>
<p v-if="errorMessage" class="create-ledger-error" role="alert">{{ errorMessage }}</p>
<button class="create-ledger-submit" type="submit" :disabled="saving || !name.trim()"><Plus :size="19" />{{ saving ? "创建中" : "创建账本" }}</button>
</form>
@ -120,8 +141,10 @@ async function createLedger() {
.ledgers-scroll { height:calc(100% - 70px); overflow-y:auto; padding:0 16px 104px; }
.ledgers-caption { margin:16px 0 10px; color:#70807c; font-size:12px; }
.ledger-list { border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
.ledger-list button { width:100%; min-height:72px; display:grid; grid-template-columns:44px minmax(0,1fr) 22px; align-items:center; gap:11px; border:0; border-bottom:1px solid #e6eeec; padding:8px 12px; background:#fff; color:#26342f; text-align:left; }
.ledger-list button:last-child { border-bottom:0; }
.ledger-list-row { min-height:72px; display:grid; grid-template-columns:minmax(0,1fr) 42px; border-bottom:1px solid #e6eeec; }
.ledger-list-row:last-child { border-bottom:0; }
.ledger-list-select { width:100%; min-height:72px; display:grid; grid-template-columns:44px minmax(0,1fr) 22px; align-items:center; gap:11px; border:0; padding:8px 8px 8px 12px; background:#fff; color:#26342f; text-align:left; }
.ledger-list-stats { width:36px; height:36px; align-self:center; display:grid; place-items:center; border:0; border-radius:8px; background:#eaf7f3; color:#087f72; }
.ledger-list-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:9px; color:#fff; box-shadow:0 4px 10px rgba(38,59,54,.16); }
.ledger-list button > span:nth-child(2) { min-width:0; display:grid; gap:2px; }
.ledger-list strong { overflow:hidden; font-size:15px; text-overflow:ellipsis; white-space:nowrap; }
@ -134,15 +157,17 @@ async function createLedger() {
.create-ledger-sheet > header { min-height:58px; display:flex; align-items:center; justify-content:space-between; }
.create-ledger-sheet > header > div { display:grid; gap:1px; }
.create-ledger-sheet > header strong { font-size:17px; }
.create-ledger-sheet > header span,.create-ledger-sheet label > span,.create-ledger-colors legend { color:#7b8884; font-size:11px; }
.create-ledger-sheet > header span,.create-ledger-sheet label > span,.create-ledger-colors legend,.create-ledger-icons legend { color:#7b8884; font-size:11px; }
.create-ledger-sheet > header button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
.create-ledger-sheet > label { min-height:64px; display:grid; gap:4px; border-top:1px solid #e2ebe8; padding:10px 2px; }
.create-ledger-sheet input,.create-ledger-sheet select { width:100%; min-width:0; border:0; padding:0; outline:0; background:#fff; color:#26342f; font-size:15px; }
.create-ledger-colors { margin:0; border:0; border-top:1px solid #e2ebe8; padding:12px 2px 15px; }
.create-ledger-colors legend { padding:0; }
.create-ledger-colors > div { display:grid; grid-template-columns:repeat(6, 38px); justify-content:space-between; gap:4px; margin-top:10px; }
.create-ledger-colors button { width:38px; height:34px; display:grid; place-items:center; border:3px solid #fff; border-radius:8px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.create-ledger-colors > div { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; width:100%; margin-top:10px; }
.create-ledger-colors button { width:100%; aspect-ratio:1; display:grid; place-items:center; border:3px solid #fff; border-radius:9px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.create-ledger-colors button.active { box-shadow:0 0 0 2px #263b36; }
.create-ledger-icons { margin:0; border:0; border-top:1px solid #e2ebe8; padding:12px 2px 15px; }
.create-ledger-icons legend { padding:0; }
.create-ledger-error { margin:0 0 10px; color:#b83232; font-size:12px; }
.create-ledger-submit { width:100%; height:48px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.create-ledger-submit:disabled { opacity:.55; }

View File

@ -0,0 +1,489 @@
<script setup lang="ts">
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
import { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, WalletCards, X } from "@lucide/vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type RangeMode = "month" | "year" | "custom" | "all";
const route = useRoute();
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const now = new Date();
const queryOpen = ref(false);
const expandedCategoryBranches = ref(new Set<string>());
function queryString(value: unknown) {
return typeof value === "string" ? value : "";
}
function queryDate(value: unknown, fallback: string) {
const candidate = queryString(value);
return /^\d{4}-\d{2}-\d{2}$/.test(candidate) ? candidate : fallback;
}
function parseList(value: unknown) {
return [...new Set(queryString(value).split(",").filter(Boolean))];
}
const rangeMode = ref<RangeMode>(["year", "custom", "all"].includes(queryString(route.query.range)) ? queryString(route.query.range) as RangeMode : "month");
const selectedMonth = ref(/^(\d{4})-(0[1-9]|1[0-2])$/.test(queryString(route.query.month)) ? queryString(route.query.month) : `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
const selectedYear = ref(/^\d{4}$/.test(queryString(route.query.year)) ? Number(queryString(route.query.year)) : now.getFullYear());
const customStart = ref(queryDate(route.query.start, `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`));
const customEnd = ref(queryDate(route.query.end, toDateInput(now)));
const selectedLedgers = ref(parseList(route.query.ledgers));
const selectedCategories = ref(parseList(route.query.cat));
const keyword = ref(queryString(route.query.q));
const effectiveLedgerIds = computed(() => selectedLedgers.value.length ? selectedLedgers.value : (ledgerStore.currentLedgerId ? [ledgerStore.currentLedgerId] : []));
const categoryTrees = computed(() => entryTypes.map((type) => ({
...type,
parents: categories[type.id].map((parent) => ({
...parent,
children: [...(parent.children ?? []), { id: "other", label: "其他", color: parent.color, tint: parent.tint, icon: WalletCards }],
})),
})));
const allRangeBounds = computed(() => {
const dates = entryStore.entries
.filter((entry) => entry.ledgerIds.some((ledgerId) => effectiveLedgerIds.value.includes(ledgerId)))
.map((entry) => new Date(entry.occurredAt).getTime())
.filter((value) => Number.isFinite(value));
if (!dates.length) return null;
const first = new Date(Math.min(...dates));
const last = new Date(Math.max(...dates));
return {
start: new Date(first.getFullYear(), first.getMonth(), first.getDate()),
end: new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1),
};
});
const rangeBounds = computed(() => {
if (rangeMode.value === "all") return allRangeBounds.value;
if (rangeMode.value === "month") {
const [year, month] = selectedMonth.value.split("-").map(Number);
const start = new Date(year, month - 1, 1);
return { start, end: new Date(year, month, 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 };
});
function matchesCategory(entry: LedgerEntry, key: string) {
const parts = key.split(":");
if (parts.length < 2 || entry.type !== parts[0]) return false;
const path = findCategoryPath(entry.type, entry.categoryId);
if (parts.length === 2) return parts[1] === "*" || path.some((item) => item.id === parts[1]);
return path[0]?.id === parts[1] && (parts[2] === "other" ? entry.categoryId === "other" : entry.categoryId === parts[2]);
}
const categoryLabel = computed(() => {
if (!selectedCategories.value.length) return "全部分类";
const labels = selectedCategories.value.map((key) => {
const separator = key.indexOf(":");
if (separator < 1) return "";
const parts = key.split(":");
const type = parts[0] as LedgerEntry["type"];
if (parts[1] === "*") return entryTypes.find((item) => item.id === type)?.label ?? "";
const path = findCategoryPath(type, parts[1]);
if (parts.length === 2) return path.map((item) => item.label).join(" · ");
return `${path[0]?.label ?? ""} · ${parts[2] === "other" ? "其他" : findCategoryPath(type, parts[2]).at(-1)?.label ?? parts[2]}`;
}).filter(Boolean);
return labels.length > 2 ? `${labels.slice(0, 2).join("、")}${labels.length}` : labels.join("、");
});
const ledgerLabel = computed(() => {
const names = effectiveLedgerIds.value.map((id) => ledgerStore.ledgers.find((ledger) => ledger.id === id)?.name).filter(Boolean) as string[];
return names.length > 2 ? `${names.slice(0, 2).join("、")}${names.length}` : names.join("、") || "全部账本";
});
const selectedCategorySummary = computed(() => {
let parentCount = 0;
let childCount = 0;
for (const tree of categoryTrees.value) {
for (const parent of tree.parents) {
const selected = selectedChildCount(tree.id, parent.id);
if (selected) parentCount += 1;
childCount += selected;
}
}
return `${parentCount} 个大类,${childCount} 个小类`;
});
const entries = computed(() => {
const bounds = rangeBounds.value;
if (!bounds) return [];
return entryStore.entries
.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
if (!entry.ledgerIds.some((ledgerId) => effectiveLedgerIds.value.includes(ledgerId)) || occurredAt < bounds.start || occurredAt >= bounds.end) return false;
const tokens = keyword.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (tokens.some((token) => !entry.note.toLocaleLowerCase().includes(token))) return false;
return !selectedCategories.value.length || selectedCategories.value.some((key) => matchesCategory(entry, key));
})
.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt));
});
const groupedEntries = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) {
const date = localDateKey(entry.occurredAt);
groups.set(date, [...(groups.get(date) ?? []), entry]);
}
return [...groups.entries()].map(([date, items]) => ({ date, entries: items }));
});
const rangeLabel = computed(() => {
if (rangeMode.value === "month") {
const [year, month] = selectedMonth.value.split("-");
return `${year}${Number(month)}`;
}
if (rangeMode.value === "year") return `${selectedYear.value}`;
if (rangeMode.value === "all" && rangeBounds.value) {
return `${formatFullDateLabel(toDateInput(rangeBounds.value.start))}${formatFullDateLabel(toDateInput(new Date(rangeBounds.value.end.getTime() - 86400000)))}`;
}
return `${customStart.value}${customEnd.value}`;
});
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
const yearOptions = computed(() => {
const years = new Set([now.getFullYear(), ...entryStore.entries.map((entry) => new Date(entry.occurredAt).getFullYear())]);
return [...years].sort((left, right) => right - left);
});
function toggleListValue(target: typeof selectedLedgers, value: string) {
target.value = target.value.includes(value) ? target.value.filter((item) => item !== value) : [...target.value, value];
}
function toggleLedger(id: string) {
if (selectedLedgers.value.length === 1 && selectedLedgers.value[0] === id) return;
toggleListValue(selectedLedgers, id);
}
function parentKey(type: LedgerEntry["type"], parentId: string) {
return `${type}:${parentId}`;
}
function childKey(type: LedgerEntry["type"], parentId: string, childId: string) {
return `${type}:${parentId}:${childId}`;
}
function isParentFullySelected(type: LedgerEntry["type"], parentId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return false;
return selectedCategories.value.includes(parentKey(type, parentId))
|| parent.children.every((child) => selectedCategories.value.includes(childKey(type, parentId, child.id)));
}
function isChildSelected(type: LedgerEntry["type"], parentId: string, childId: string) {
return selectedCategories.value.includes(parentKey(type, parentId))
|| selectedCategories.value.includes(childKey(type, parentId, childId));
}
function isParentPartiallySelected(type: LedgerEntry["type"], parentId: string) {
return selectedChildCount(type, parentId) > 0 && !isParentFullySelected(type, parentId);
}
function selectedChildCount(type: LedgerEntry["type"], parentId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return 0;
if (selectedCategories.value.includes(parentKey(type, parentId))) return parent.children.length;
return parent.children.filter((child) => selectedCategories.value.includes(childKey(type, parentId, child.id))).length;
}
function selectedTypeCount(type: LedgerEntry["type"]) {
return categoryTrees.value.find((tree) => tree.id === type)?.parents.reduce((total, parent) => total + selectedChildCount(type, parent.id), 0) ?? 0;
}
function isCategoryBranchExpanded(type: LedgerEntry["type"], parentId: string) {
return expandedCategoryBranches.value.has(parentKey(type, parentId));
}
function toggleCategoryBranch(type: LedgerEntry["type"], parentId: string) {
const key = parentKey(type, parentId);
const next = new Set(expandedCategoryBranches.value);
if (next.has(key)) next.delete(key);
else next.add(key);
expandedCategoryBranches.value = next;
}
function toggleAllCategory(type: LedgerEntry["type"], parentId: string) {
const key = parentKey(type, parentId);
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return;
const next = selectedCategories.value.filter((item) => item !== key && !item.startsWith(`${key}:`));
if (!isParentFullySelected(type, parentId)) next.push(key);
selectedCategories.value = next;
}
function toggleChildCategory(type: LedgerEntry["type"], parentId: string, childId: string) {
const parent = categoryTrees.value.find((tree) => tree.id === type)?.parents.find((item) => item.id === parentId);
if (!parent) return;
const allChildren = parent.children.map((child) => childKey(type, parentId, child.id));
let next = selectedCategories.value.filter((item) => item !== parentKey(type, parentId) && !item.startsWith(`${parentKey(type, parentId)}:`));
const selected = isChildSelected(type, parentId, childId);
if (selected && selectedCategories.value.includes(parentKey(type, parentId))) {
next.push(...allChildren.filter((key) => key !== childKey(type, parentId, childId)));
} else if (!selected) {
next = [...selectedCategories.value, childKey(type, parentId, childId)];
} else {
next = selectedCategories.value.filter((key) => key !== childKey(type, parentId, childId));
}
selectedCategories.value = [...new Set(next)];
}
function chooseMonth(month: number) {
selectedMonth.value = `${selectedYear.value}-${String(month + 1).padStart(2, "0")}`;
rangeMode.value = "month";
}
function shiftYear(offset: -1 | 1) {
const index = yearOptions.value.indexOf(selectedYear.value);
const next = yearOptions.value[index + (offset < 0 ? 1 : -1)];
if (next !== undefined) selectedYear.value = next;
}
function syncFromRoute() {
const range = queryString(route.query.range);
rangeMode.value = ["year", "custom", "all"].includes(range) ? range as RangeMode : "month";
selectedMonth.value = /^(\d{4})-(0[1-9]|1[0-2])$/.test(queryString(route.query.month)) ? queryString(route.query.month) : selectedMonth.value;
selectedYear.value = /^\d{4}$/.test(queryString(route.query.year)) ? Number(route.query.year) : selectedYear.value;
customStart.value = queryDate(route.query.start, customStart.value);
customEnd.value = queryDate(route.query.end, customEnd.value);
selectedLedgers.value = parseList(route.query.ledgers);
selectedCategories.value = parseList(route.query.cat);
keyword.value = queryString(route.query.q);
}
watch(() => route.query, syncFromRoute, { deep: true });
watch([rangeMode, selectedMonth, selectedYear, customStart, customEnd, selectedLedgers, selectedCategories, keyword], () => {
if (route.name !== "query") return;
const query = { ...route.query };
query.range = rangeMode.value;
query.month = selectedMonth.value;
query.year = String(selectedYear.value);
query.start = customStart.value;
query.end = customEnd.value;
if (selectedLedgers.value.length) query.ledgers = selectedLedgers.value.join(",");
else delete query.ledgers;
if (selectedCategories.value.length) query.cat = selectedCategories.value.join(",");
else delete query.cat;
if (keyword.value.trim()) query.q = keyword.value.trim();
else delete query.q;
void router.replace({ query });
});
onMounted(async () => {
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
if (!selectedLedgers.value.length && ledgerStore.currentLedgerId) selectedLedgers.value = [ledgerStore.currentLedgerId];
});
function entryCategoryLabel(entry: LedgerEntry) {
return findCategoryPath(entry.type, entry.categoryId).map((item) => item.label).join(" · ")
|| (entry.type === "expense" ? "支出" : "收入");
}
function entryIcon(entry: LedgerEntry) {
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((item) => item.id === entry.type)?.icon ?? ReceiptText;
}
function entryAmount(entry: LedgerEntry) {
if (entry.baseAmount !== null) return `${entry.type === "expense" ? "" : "+"}¥${money(entry.baseAmount)}`;
return `${entry.type === "expense" ? "" : "+"}${formatCurrencyAmount(entry.amount, entry.currency)}`;
}
function dayLabel(value: string) {
const date = new Date(`${value}T00:00:00`);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}日 · ${new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date)}`;
}
function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function toDateInput(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function formatFullDateLabel(value: string) {
const date = fromDateInput(value);
return date ? `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}` : value;
}
function fromDateInput(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
function localDateKey(value: string) {
return toDateInput(new Date(value));
}
</script>
<template>
<main class="app-shell stats-entries-shell">
<header class="stats-entries-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
<button type="button" aria-label="返回" title="返回" @click="router.back()"><ArrowLeft :size="21" /></button>
<div><strong>查询</strong><span>{{ rangeLabel }} · {{ ledgerLabel }} · {{ selectedCategorySummary }}</span></div>
<button type="button" aria-label="打开查询条件" title="查询条件" @click="queryOpen = true"><Filter :size="20" /></button>
</header>
<section class="query-summary">
<button type="button" @click="queryOpen = true"><span>账本</span><strong>{{ ledgerLabel }}</strong><ChevronDown :size="15" /></button>
<button type="button" @click="queryOpen = true"><span>时间</span><strong>{{ rangeLabel }}</strong><ChevronDown :size="15" /></button>
<button type="button" @click="queryOpen = true"><span>分类</span><strong>{{ selectedCategorySummary }}</strong><ChevronDown :size="15" /></button>
<label class="query-keyword"><span>备注</span><input v-model="keyword" type="search" placeholder="支持模糊匹配" autocomplete="off" /></label>
</section>
<div class="stats-entries-scroll">
<div v-if="!entries.length" class="stats-entries-empty"><ReceiptText :size="28" /><strong>没有符合条件的流水</strong><span>{{ rangeLabel }}暂无{{ categoryLabel }}记录</span></div>
<section v-for="group in groupedEntries" :key="group.date" class="stats-entries-day">
<header><strong>{{ dayLabel(group.date) }}</strong><span>{{ group.entries.length }} </span></header>
<RouterLink v-for="entry in group.entries" :key="entry.id" :to="`/entries/${entry.id}`" class="stats-entry-row">
<span class="stats-entry-icon" :style="{ color: findCategory(entry.categoryId)?.color ?? '#087f72', background: findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
<span class="stats-entry-copy"><strong>{{ entry.note || entryCategoryLabel(entry) }}</strong><small><CircleUserRound :size="12" />{{ entryCategoryLabel(entry) }}</small></span>
<strong class="stats-entry-amount" :class="entry.type">{{ entryAmount(entry) }}</strong>
</RouterLink>
</section>
</div>
<Transition name="month-picker">
<div v-if="queryOpen" class="month-picker-layer query-layer">
<button class="month-picker-scrim" type="button" aria-label="关闭查询条件" @click="queryOpen = false"></button>
<section class="month-picker-modal query-modal" role="dialog" aria-modal="true" aria-label="查询条件">
<header><span></span><strong>查询条件</strong><button type="button" aria-label="关闭" title="关闭" @click="queryOpen = false"><X :size="20" /></button></header>
<div class="query-modal-scroll">
<section class="query-filter-section"><strong>账本</strong><div class="query-option-grid">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" class="query-ledger-option" :class="{ selected: selectedLedgers.includes(ledger.id) }" @click="toggleLedger(ledger.id)"><span class="query-ledger-icon" :style="{ background: ledgerTheme(ledger.theme).gradient }"><component :is="ledgerIconComponent(ledger.icon)" :size="17" /></span><span>{{ ledger.name }}</span><Check v-if="selectedLedgers.includes(ledger.id)" :size="15" /></button>
</div></section>
<section class="query-filter-section"><strong>时间范围</strong><div class="stats-range-modes"><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><button type="button" :class="{ active: rangeMode === 'all' }" @click="rangeMode = 'all'">全部</button></div>
<template v-if="rangeMode === 'month'"><div class="query-year-nav"><button type="button" @click="shiftYear(-1)"><ChevronLeft :size="18" /></button><strong>{{ selectedYear }}</strong><button type="button" @click="shiftYear(1)"><ChevronRight :size="18" /></button></div><div class="query-month-grid"><button v-for="month in 12" :key="month" type="button" :class="{ selected: selectedMonth === `${selectedYear}-${String(month).padStart(2, '0')}` }" @click="chooseMonth(month - 1)">{{ month }}</button></div></template>
<div v-else-if="rangeMode === 'year'" class="query-year-list"><button v-for="year in yearOptions" :key="year" type="button" :class="{ selected: selectedYear === year }" @click="selectedYear = year">{{ year }}</button></div>
<div v-else-if="rangeMode === 'all'" class="query-all-range">{{ rangeLabel }}</div>
<div v-else class="query-custom-range"><label>开始<input v-model="customStart" type="date" /></label><label>结束<input v-model="customEnd" type="date" /></label></div>
</section>
<label class="query-modal-keyword"><strong>备注</strong><input v-model="keyword" type="search" placeholder="支持模糊匹配,关键字用空格分隔" autocomplete="off" /></label>
<section class="query-filter-section"><strong>分类</strong>
<div class="query-category-columns">
<div v-for="tree in categoryTrees" :key="tree.id" class="query-category-tree">
<header><span :style="{ color: tree.color }">{{ tree.label }}</span><b v-if="selectedTypeCount(tree.id)">{{ selectedTypeCount(tree.id) }}</b></header>
<div v-for="parent in tree.parents" :key="parent.id" class="query-category-branch">
<div class="query-category-parent"><button class="query-category-check" type="button" :class="{ selected: isParentFullySelected(tree.id, parent.id), partial: isParentPartiallySelected(tree.id, parent.id) }" :aria-label="`${isParentFullySelected(tree.id, parent.id) ? '取消' : '选择'}全部${parent.label}`" :aria-checked="isParentPartiallySelected(tree.id, parent.id) ? 'mixed' : isParentFullySelected(tree.id, parent.id)" role="checkbox" @click.stop="toggleAllCategory(tree.id, parent.id)"><Check v-if="isParentFullySelected(tree.id, parent.id)" :size="14" /><Minus v-else-if="isParentPartiallySelected(tree.id, parent.id)" :size="14" /></button><button type="button" class="query-category-main" :class="{ selected: isParentFullySelected(tree.id, parent.id) }" :aria-label="`${isCategoryBranchExpanded(tree.id, parent.id) ? '收起' : '展开'}${parent.label}`" @click="toggleCategoryBranch(tree.id, parent.id)"><component :is="parent.icon" :size="17" /><span>{{ parent.label }}</span><b v-if="selectedChildCount(tree.id, parent.id)">{{ selectedChildCount(tree.id, parent.id) }}</b></button><button class="query-category-toggle" type="button" :aria-label="`${isCategoryBranchExpanded(tree.id, parent.id) ? '收起' : '展开'}${parent.label}`" @click="toggleCategoryBranch(tree.id, parent.id)"><ChevronDown v-if="isCategoryBranchExpanded(tree.id, parent.id)" :size="14" /><ChevronRight v-else :size="14" /></button></div>
<div v-if="isCategoryBranchExpanded(tree.id, parent.id)" class="query-category-children"><button type="button" class="query-category-all" :class="{ selected: isParentFullySelected(tree.id, parent.id) }" @click="toggleAllCategory(tree.id, parent.id)">全部<Check v-if="isParentFullySelected(tree.id, parent.id)" :size="13" /></button><button v-for="child in parent.children" :key="child.id" type="button" :class="{ selected: isChildSelected(tree.id, parent.id, child.id) }" @click="toggleChildCategory(tree.id, parent.id, child.id)">{{ child.label }}<Check v-if="isChildSelected(tree.id, parent.id, child.id)" :size="13" /></button></div>
</div>
</div>
</div>
</section>
</div>
<button class="query-done" type="button" @click="queryOpen = false">完成</button>
</section>
</div>
</Transition>
<QuickEntryHost />
</main>
</template>
<style scoped>
.stats-entries-shell { display:flex; flex-direction:column; background:#f5f8f7; }
.stats-entries-header { height:74px; display:grid; grid-template-columns:40px 1fr 40px; align-items:center; gap:8px; padding:14px 16px 8px; background:var(--ledger-gradient,#087f72); color:#fff; }
.stats-entries-header > button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:rgba(255,255,255,.14); color:#fff; }
.stats-entries-header > div { min-width:0; display:grid; gap:2px; text-align:center; }
.stats-entries-header strong { font-size:17px; }
.stats-entries-header span { overflow:hidden; color:rgba(255,255,255,.76); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
.query-summary { position:sticky; top:0; z-index:2; display:grid; grid-template-columns:minmax(0,1fr); gap:7px; border-bottom:1px solid #dce7e4; padding:10px 16px; background:#f7faf9; box-shadow:0 2px 7px rgba(31,57,51,.06); }
.query-summary > button,.query-keyword { min-width:0; min-height:42px; display:grid; grid-template-columns:auto minmax(0,1fr) 15px; align-items:center; gap:5px; border:1px solid #d2dfdc; border-radius:8px; padding:0 9px; background:#fff; color:#52645e; text-align:left; }
.query-summary > button span,.query-keyword span { color:#84928e; font-size:10px; }
.query-summary > button strong { overflow:hidden; color:#344640; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.query-keyword { grid-column:auto; grid-template-columns:auto minmax(0,1fr); }
.query-keyword input,.query-modal-keyword input { width:100%; min-width:0; border:0; outline:0; background:transparent; color:#26342f; font-size:13px; }
.query-layer { z-index:17; }
.query-modal { max-height:90%; display:flex; flex-direction:column; overflow:hidden; }
.query-modal > header,.query-done { flex:0 0 auto; }
.query-modal-scroll { min-height:0; flex:1; overflow-y:auto; overscroll-behavior:contain; }
.query-filter-section { border-bottom:1px solid #e1ebe8; padding:14px 0; }
.query-filter-section > strong,.query-modal-keyword > strong { display:block; margin-bottom:9px; color:#7b8884; font-size:11px; }
.query-filter-section .stats-range-modes { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
.query-filter-section .stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
.query-filter-section .stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.query-option-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.query-option-grid button { min-height:40px; display:flex; align-items:center; justify-content:space-between; gap:5px; border:1px solid #e1ebe8; border-radius:8px; padding:0 9px; background:#fff; color:#344640; text-align:left; }
.query-option-grid button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; font-weight:720; }
.query-ledger-option { justify-content:flex-start!important; }
.query-ledger-option > span:nth-child(2) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.query-ledger-icon { width:26px; height:26px; display:grid; flex:0 0 auto; place-items:center; border-radius:6px; color:#fff; }
.query-year-nav { display:grid; grid-template-columns:38px 1fr 38px; align-items:center; gap:4px; margin:14px 0 10px; }
.query-year-nav button { min-height:34px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#536762; }
.query-year-nav strong { text-align:center; color:#087f72; font-size:15px; }
.query-month-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:7px; margin-top:8px; }
.query-month-grid button,.query-year-list button { min-height:42px; border:1px solid #e1ebe8; border-radius:8px; background:#fff; color:#344640; font-size:12px; }
.query-month-grid button.selected,.query-year-list button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; font-weight:720; }
.query-year-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; margin-top:12px; }
.query-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; margin-top:12px; }
.query-custom-range label { display:grid; gap:4px; color:#7b8884; font-size:10px; }
.query-custom-range input { width:100%; min-width:0; height:38px; border:1px solid #d2dfdc; border-radius:7px; padding:0 6px; background:#fff; color:#344640; font-size:12px; }
.query-all-range { margin-top:12px; border:1px solid #d2dfdc; border-radius:8px; padding:11px 10px; background:#f8fbfa; color:#536762; font-size:12px; text-align:center; }
.query-category-head { display:flex; align-items:center; justify-content:space-between; min-height:36px; margin-bottom:7px; color:#536762; font-size:12px; }
.query-category-head button { display:flex; align-items:center; gap:3px; border:0; background:transparent; color:#087f72; font-size:12px; }
.query-category-list { display:grid; gap:6px; }
.query-category-list > div { display:grid; grid-template-columns:minmax(0,1fr) 36px; gap:5px; }
.query-category-main,.query-category-next { min-height:40px; display:flex; align-items:center; gap:7px; border:1px solid #e1ebe8; background:#fff; color:#344640; }
.query-category-main { border-radius:8px 0 0 8px; padding:0 9px; text-align:left; }
.query-category-main.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; font-weight:720; }
.query-category-next { justify-content:center; border-radius:0 8px 8px 0; color:#7b8884; }
.query-category-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; align-items:start; }
.query-category-tree { min-width:0; border:1px solid #e1ebe8; border-radius:9px; padding:7px; background:#fbfdfc; }
.query-category-tree > header { min-height:27px; display:flex; align-items:center; justify-content:space-between; padding:0 3px 6px; border-bottom:1px solid #e5edeb; font-size:12px; font-weight:760; }
.query-category-tree > header b,.query-category-parent b { min-width:17px; height:17px; display:inline-grid; place-items:center; border-radius:9px; background:#eaf7f3; color:#087f72; font-size:10px; font-weight:760; }
.query-category-branch { padding:6px 0 4px; border-bottom:1px solid #edf2f0; }
.query-category-branch:last-child { border-bottom:0; }
.query-category-parent { display:grid; grid-template-columns:22px minmax(0,1fr) 18px; gap:3px; }
.query-category-parent button { min-width:0; min-height:30px; display:flex; align-items:center; gap:4px; border:0; border-radius:6px; padding:0 4px; background:transparent; color:#344640; font-size:12px; text-align:left; }
.query-category-check { width:20px; height:20px; min-height:20px!important; justify-self:center; align-self:center; justify-content:center; border:1px solid #cbd9d5!important; border-radius:5px!important; padding:0!important; background:#fff!important; color:#fff!important; }
.query-category-check.selected { border-color:#087f72!important; background:#087f72!important; }
.query-category-check.partial { border-color:#087f72!important; background:#eaf7f3!important; color:#087f72!important; }
.query-category-main { border:0!important; background:transparent!important; }
.query-category-main.selected { background:#eaf7f3!important; color:#087f72; font-weight:720; }
.query-category-parent button span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.query-category-parent button b { margin-left:auto; }
.query-category-toggle { width:18px; min-height:30px; display:grid; place-items:center; border:0; border-radius:5px; background:transparent; color:#a1ada9; }
.query-category-children { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:3px; padding:4px 0 0 20px; }
.query-category-children button { min-width:0; min-height:27px; display:flex; align-items:center; justify-content:space-between; gap:2px; overflow:hidden; border:0; border-radius:5px; padding:0 3px; background:transparent; color:#71807c; font-size:11px; text-align:left; text-overflow:ellipsis; white-space:nowrap; }
.query-category-children button.selected { background:#eaf7f3; color:#087f72; font-weight:720; }
.query-category-children .query-category-all { color:#536762; font-weight:680; }
.query-modal-keyword { display:grid; gap:6px; border-bottom:1px solid #e1ebe8; padding:14px 0; }
.query-modal-keyword input { min-height:40px; border:1px solid #d2dfdc; border-radius:8px; padding:0 10px; background:#f8fbfa; }
.query-done { width:100%; min-height:44px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.stats-entries-scroll { min-height:0; flex:1; overflow-y:auto; padding:0 16px 104px; }
.stats-entries-day { margin-top:14px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
.stats-entries-day > header { min-height:42px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #e4ece9; padding:0 12px; }
.stats-entries-day > header strong { color:#64726f; font-size:12px; }
.stats-entries-day > header span { color:#8a9994; font-size:11px; }
.stats-entry-row { min-height:64px; display:grid; grid-template-columns:36px minmax(0,1fr) auto; align-items:center; gap:10px; border-bottom:1px solid #edf2f0; padding:8px 12px; color:#26342f; text-decoration:none; }
.stats-entry-row:last-child { border-bottom:0; }
.stats-entry-icon { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; }
.stats-entry-copy { min-width:0; display:grid; gap:4px; }
.stats-entry-copy strong { overflow:hidden; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
.stats-entry-copy small { display:flex; align-items:center; gap:3px; overflow:hidden; color:#7d8986; font-size:10px; text-overflow:ellipsis; white-space:nowrap; }
.stats-entry-amount { font-size:12px; font-variant-numeric:tabular-nums; white-space:nowrap; }
.stats-entry-amount.income { color:#0d8b67; }
.stats-entry-amount.expense { color:#d84c36; }
.stats-entries-empty { min-height:260px; display:grid; place-items:center; align-content:center; gap:8px; color:#7b8884; font-size:13px; text-align:center; }
.stats-entries-empty strong { color:#536762; }
</style>

View File

@ -1,29 +1,114 @@
<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 { ArrowLeft, CalendarDays, ChartNoAxesCombined, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, CircleUserRound, TrendingDown, TrendingUp, WalletCards, X } from "@lucide/vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories";
import { entryTypes, findCategoryPath } 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";
type RangeMode = "month" | "year" | "custom" | "all";
type DailyView = "calendar" | "line";
type FlowGranularity = "day" | "month" | "year";
const router = useRouter();
const route = useRoute();
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));
function queryString(value: unknown) {
return typeof value === "string" ? value : "";
}
function isStatsTab(value: string): value is StatsTab {
return value === "flow" || value === "category" || value === "member";
}
function isRangeMode(value: string): value is RangeMode {
return value === "month" || value === "year" || value === "custom" || value === "all";
}
function queryMonth(value: string) {
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function queryYear(value: string) {
return /^\d{4}$/.test(value) ? Number(value) : now.getFullYear();
}
function queryDate(value: string, fallback: string) {
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : fallback;
}
const tabQuery = queryString(route.query.tab);
const rangeQuery = queryString(route.query.range);
const dailyViewQuery = queryString(route.query.view);
const granularityQuery = queryString(route.query.granularity);
const activeTab = ref<StatsTab>(isStatsTab(tabQuery) ? tabQuery : "flow");
const rangeMode = ref<RangeMode>(isRangeMode(rangeQuery) ? rangeQuery : "month");
const dailyView = ref<DailyView>(dailyViewQuery === "line" ? "line" : "calendar");
const flowGranularity = ref<FlowGranularity>(granularityQuery === "month" || granularityQuery === "year" ? granularityQuery : "day");
const rangePickerOpen = ref(false);
const rangePickerView = ref<"months" | "years">("months");
const rangePickerYear = ref(now.getFullYear());
const selectedMonth = ref(queryMonth(queryString(route.query.month)));
const selectedYear = ref(queryYear(queryString(route.query.year)));
const customStart = ref(queryDate(queryString(route.query.start), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`));
const customEnd = ref(queryDate(queryString(route.query.end), toDateInput(now)));
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
function syncStatsStateFromRoute() {
const tab = queryString(route.query.tab);
const range = queryString(route.query.range);
activeTab.value = isStatsTab(tab) ? tab : "flow";
rangeMode.value = isRangeMode(range) ? range : "month";
dailyView.value = queryString(route.query.view) === "line" ? "line" : "calendar";
const granularity = queryString(route.query.granularity);
flowGranularity.value = granularity === "month" || granularity === "year" ? granularity : "day";
selectedMonth.value = queryMonth(queryString(route.query.month));
selectedYear.value = queryYear(queryString(route.query.year));
customStart.value = queryDate(queryString(route.query.start), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`);
customEnd.value = queryDate(queryString(route.query.end), toDateInput(now));
}
watch(() => route.query, syncStatsStateFromRoute, { deep: true });
watch(
[activeTab, rangeMode, dailyView, flowGranularity, selectedMonth, selectedYear, customStart, customEnd],
() => {
if (route.name !== "stats") return;
const query = {
...route.query,
tab: activeTab.value,
range: rangeMode.value,
view: dailyView.value,
granularity: flowGranularity.value,
month: selectedMonth.value,
year: String(selectedYear.value),
start: customStart.value,
end: customEnd.value,
};
const current = [
queryString(route.query.tab),
queryString(route.query.range),
queryString(route.query.view),
queryString(route.query.granularity),
queryString(route.query.month),
queryString(route.query.year),
queryString(route.query.start),
queryString(route.query.end),
];
const next = [query.tab, query.range, query.view, query.granularity, query.month, query.year, query.start, query.end];
if (current.every((value, index) => value === next[index])) return;
void router.replace({ query });
},
);
const yearOptions = computed(() => {
const years = new Set<number>();
for (let year = now.getFullYear() + 1; year >= now.getFullYear() - 5; year -= 1) years.add(year);
@ -31,7 +116,21 @@ const yearOptions = computed(() => {
return [...years].sort((left, right) => right - left);
});
const allRangeBounds = computed(() => {
const dates = entryStore.entries
.filter((entry) => entry.ledgerIds.includes(ledgerStore.currentLedgerId))
.map((entry) => new Date(entry.occurredAt).getTime())
.filter((value) => Number.isFinite(value));
if (!dates.length) return null;
const first = new Date(Math.min(...dates));
const last = new Date(Math.max(...dates));
const start = new Date(first.getFullYear(), first.getMonth(), first.getDate());
const end = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
return { start, end };
});
const rangeBounds = computed(() => {
if (rangeMode.value === "all") return allRangeBounds.value;
if (rangeMode.value === "month") {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
if (!match) return null;
@ -55,6 +154,9 @@ const rangeLabel = computed(() => {
return match ? `${match[1]}${Number(match[2])}` : "所选月份";
}
if (rangeMode.value === "year") return `${selectedYear.value}`;
if (rangeMode.value === "all" && rangeBounds.value) {
return `${formatFullDateLabel(toDateInput(rangeBounds.value.start))}${formatFullDateLabel(toDateInput(new Date(rangeBounds.value.end.getTime() - 86400000)))}`;
}
if (!rangeBounds.value) return "自定义日期";
return `${formatDateLabel(customStart.value)}${formatDateLabel(customEnd.value)}`;
});
@ -89,22 +191,175 @@ 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 }>();
const flowStats = computed(() => {
if (flowGranularity.value === "day") return dailyStats.value;
const groups = new Map<string, LedgerEntry[]>();
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);
const date = new Date(entry.occurredAt);
const key = flowGranularity.value === "month"
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
: String(date.getFullYear());
groups.set(key, [...(groups.get(key) ?? []), entry]);
}
return Array.from(groups, ([categoryId, summary]) => ({
categoryId,
category: findCategory(categoryId),
...summary,
})).sort((left, right) => right.amount - left.amount);
return [...groups.entries()]
.map(([date, items]) => ({ date, label: flowGranularity.value === "month" ? date.replace("-", "/") : `${date}`, ...summarize(items) }))
.sort((left, right) => right.date.localeCompare(left.date));
});
const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map((item) => item.amount)));
const calendarMonthStart = computed(() => {
const bounds = rangeBounds.value;
return bounds ? new Date(bounds.start.getFullYear(), bounds.start.getMonth(), 1) : null;
});
const calendarMonthLabel = computed(() => {
const start = calendarMonthStart.value;
return start ? `${start.getFullYear()}${start.getMonth() + 1}` : "暂无日期";
});
const calendarDays = computed(() => {
const start = calendarMonthStart.value;
if (!start) return [];
const summary = new Map(dailyStats.value.map((day) => [day.date, day]));
const daysInMonth = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate();
const cells: Array<{ date: string; day: number; income: number; expense: number } | null> = Array.from({ length: start.getDay() }, () => null);
for (let day = 1; day <= daysInMonth; day += 1) {
const date = toDateInput(new Date(start.getFullYear(), start.getMonth(), day));
const item = summary.get(date);
cells.push({ date, day, 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])));
function linePoints(field: "income" | "expense") {
const values = lineStats.value;
if (!values.length) return "";
return values.map((day, index) => {
const x = values.length === 1 ? 168 : 32 + (index / (values.length - 1)) * 272;
const y = 154 - (day[field] / lineMaxAmount.value) * 126;
return `${x},${y}`;
}).join(" ");
}
function chartPoints(field: "income" | "expense") {
return lineStats.value.map((day, index) => {
const x = lineStats.value.length === 1 ? 168 : 32 + (index / (lineStats.value.length - 1)) * 272;
const y = 154 - (day[field] / lineMaxAmount.value) * 126;
return { x, y, key: `${field}-${day.date}` };
});
}
type CategorySummaryNode = {
key: string;
label: string;
color: string;
tint: string;
icon: typeof entryTypes[number]["icon"];
amount: number;
count: number;
children: CategorySummaryNode[];
};
const expandedCategoryKeys = ref(new Set<string>());
const categoryTree = computed(() => {
const roots = new Map<string, CategorySummaryNode>();
for (const entry of convertedEntries.value) {
const type = entryTypes.find((item) => item.id === entry.type);
if (!type) continue;
const root = roots.get(entry.type) ?? {
key: `${entry.type}:*`,
label: type.label,
color: type.color,
tint: type.tint,
icon: type.icon,
amount: 0,
count: 0,
children: [],
};
root.amount += entry.baseAmount!;
root.count += 1;
const path = findCategoryPath(entry.type, entry.categoryId);
const groupCategory = path[0] ?? {
id: "uncategorized",
label: "未分类",
color: type.color,
tint: type.tint,
icon: WalletCards,
};
let group = root.children.find((item) => item.key === `${entry.type}:${groupCategory.id}`);
if (!group) {
group = {
key: `${entry.type}:${groupCategory.id}`,
label: groupCategory.label,
color: groupCategory.color,
tint: groupCategory.tint,
icon: groupCategory.icon,
amount: 0,
count: 0,
children: [],
};
root.children.push(group);
}
group.amount += entry.baseAmount!;
group.count += 1;
const leafCategory = path[1];
if (leafCategory) {
let leaf = group.children.find((item) => item.key === `${entry.type}:${groupCategory.id}:${leafCategory.id}`);
if (!leaf) {
leaf = {
key: `${entry.type}:${groupCategory.id}:${leafCategory.id}`,
label: leafCategory.label,
color: leafCategory.color,
tint: leafCategory.tint,
icon: leafCategory.icon,
amount: 0,
count: 0,
children: [],
};
group.children.push(leaf);
}
leaf.amount += entry.baseAmount!;
leaf.count += 1;
}
roots.set(entry.type, root);
}
return [...roots.values()];
});
const categoryRows = computed(() => {
const rows: Array<CategorySummaryNode & { level: number; expanded: boolean }> = [];
const expanded = expandedCategoryKeys.value;
const append = (node: CategorySummaryNode, level: number) => {
const isExpanded = expanded.has(node.key);
rows.push({ ...node, level, expanded: isExpanded });
if (isExpanded) for (const child of node.children) append(child, level + 1);
};
for (const root of categoryTree.value) append(root, 0);
return rows;
});
const maxCategoryAmount = computed(() => Math.max(1, ...categoryTree.value.map((item) => item.amount)));
function toggleCategoryNode(node: CategorySummaryNode) {
if (!node.children.length) return;
const next = new Set(expandedCategoryKeys.value);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
expandedCategoryKeys.value = next;
}
function openCategoryEntries(node: CategorySummaryNode) {
const { tab: _tab, category: _category, ...restQuery } = route.query;
const query: LocationQueryRaw = { ...restQuery, category: node.key };
query.ledgers = ledgerStore.currentLedgerId;
query.cat = node.key;
void router.push({ name: "query", query });
}
const memberStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
@ -137,6 +392,13 @@ function money(value: number) {
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function compactDailyAmount(value: number) {
const amount = value / 100;
if (amount >= 10000) return `${(amount / 10000).toFixed(1)}`;
if (amount >= 1000) return `${(amount / 1000).toFixed(1)}k`;
return amount.toFixed(0);
}
function toDateInput(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
@ -152,40 +414,127 @@ function formatDateLabel(value: string) {
return date ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : value;
}
function formatFullDateLabel(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()}`;
}
function openRangePicker() {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
rangePickerYear.value = match ? Number(match[1]) : selectedYear.value;
rangePickerView.value = rangeMode.value === "year" ? "years" : "months";
rangePickerOpen.value = true;
}
function closeRangePicker() {
rangePickerOpen.value = false;
}
function chooseRangeMode(mode: RangeMode) {
rangeMode.value = mode;
rangePickerView.value = mode === "year" ? "years" : "months";
}
function selectStatsMonth(month: number) {
selectedMonth.value = `${rangePickerYear.value}-${String(month + 1).padStart(2, "0")}`;
rangeMode.value = "month";
closeRangePicker();
}
function isSelectedStatsMonth(month: number) {
return selectedMonth.value === `${rangePickerYear.value}-${String(month).padStart(2, "0")}`;
}
function selectStatsYear(year: number) {
selectedYear.value = year;
rangeMode.value = "year";
closeRangePicker();
}
function shiftRangePickerYear(offset: -1 | 1) {
const years = yearOptions.value;
const index = years.indexOf(rangePickerYear.value);
const target = years[index + (offset < 0 ? 1 : -1)];
if (target !== undefined) rangePickerYear.value = target;
}
function shiftCalendarMonth(offset: -1 | 1) {
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.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";
}
</script>
<template>
<main class="app-shell stats-shell">
<header class="stats-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
<button class="stats-back-button" type="button" aria-label="返回" title="返回" @click="router.back()"><ArrowLeft :size="20" /></button>
<strong>统计</strong>
<button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: '/stats' } })">
<button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: route.fullPath } })">
{{ ledgerStore.currentLedger?.name ?? "选择账本" }}
<ChevronDown :size="15" />
</button>
</header>
<Transition name="month-picker">
<div v-if="rangePickerOpen" class="month-picker-layer">
<button class="month-picker-scrim" type="button" aria-label="关闭时间范围选择" @click="closeRangePicker"></button>
<section class="month-picker-modal stats-range-modal" role="dialog" aria-modal="true" aria-label="选择统计时间范围">
<header>
<button v-if="rangePickerView === 'years'" type="button" aria-label="返回月份选择" title="返回月份选择" @click="rangePickerView = 'months'"><ChevronLeft :size="20" /></button>
<span v-else></span>
<strong>统计时间范围</strong>
<button type="button" aria-label="关闭" title="关闭" @click="closeRangePicker"><X :size="20" /></button>
</header>
<div class="stats-range-modes" role="group" aria-label="时间范围类型">
<button type="button" :class="{ active: rangeMode === 'month' }" @click="chooseRangeMode('month')"></button>
<button type="button" :class="{ active: rangeMode === 'year' }" @click="chooseRangeMode('year')"></button>
<button type="button" :class="{ active: rangeMode === 'custom' }" @click="chooseRangeMode('custom')">自定义</button>
<button type="button" :class="{ active: rangeMode === 'all' }" @click="chooseRangeMode('all')">全部</button>
</div>
<template v-if="rangeMode === 'month' && rangePickerView === 'months'">
<div class="stats-picker-year-nav">
<button type="button" aria-label="上一年" title="上一年" @click="shiftRangePickerYear(-1)"><ChevronLeft :size="18" /></button>
<button type="button" class="selected" @click="rangePickerView = 'years'">{{ rangePickerYear }}</button>
<button type="button" aria-label="下一年" title="下一年" @click="shiftRangePickerYear(1)"><ChevronDown :size="16" /></button>
</div>
<div class="stats-month-grid">
<button v-for="month in 12" :key="month" type="button" :class="{ selected: isSelectedStatsMonth(month) }" @click="selectStatsMonth(month - 1)">{{ month }}</button>
</div>
</template>
<div v-else-if="rangeMode === 'year'" class="stats-year-list">
<button v-for="year in yearOptions" :key="year" type="button" :class="{ selected: selectedYear === year }" @click="selectStatsYear(year)">{{ year }}</button>
</div>
<div v-else-if="rangeMode === 'all'" class="stats-all-range"><span>{{ rangeLabel }}</span></div>
<div v-else class="stats-custom-range stats-custom-modal">
<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>
<p v-if="!rangeBounds" role="alert">开始日期不能晚于结束日期</p>
<button type="button" class="stats-range-confirm" :disabled="!rangeBounds" @click="closeRangePicker">完成</button>
</div>
</section>
</div>
</Transition>
<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>
<button class="stats-range-trigger" type="button" @click="openRangePicker">
<span>{{ rangeLabel }}</span>
<ChevronDown :size="16" />
</button>
</section>
<section class="stats-summary" :aria-label="`${rangeLabel}统计`">
@ -209,31 +558,39 @@ function dailyLabel(value: string) {
</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>
<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>
</span>
</div>
<div><small class="income">+{{ money(day.income) }}</small><small class="expense">{{ money(day.expense) }}</small></div>
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
</div>
<div v-else class="daily-line-chart">
<div v-if="lineStats.length" class="line-chart-wrap"><svg viewBox="0 0 320 190" role="img" aria-label="收支折线图"><line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="154" x2="304" y2="154" class="chart-axis-line" /><line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" /><text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text><polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" /><circle v-for="point in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot income-dot" /><circle v-for="point in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot expense-dot" /></svg><div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div></div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></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">
<header><strong>分类金额</strong><span>收入 / 支出</span></header>
<div v-for="item in categoryRows" :key="item.key" class="category-stat-row" :class="[`level-${item.level}`, { expandable: item.children.length, expanded: item.expanded }]" :style="{ '--category-indent': `${item.level * 18}px` }" role="button" tabindex="0" @click="toggleCategoryNode(item)" @keydown.enter="toggleCategoryNode(item)">
<span class="category-stat-expand-indicator" aria-hidden="true"><ChevronUp v-if="item.children.length && item.expanded" :size="15" /><ChevronDown v-else-if="item.children.length" :size="15" /></span>
<span
class="category-stat-icon"
:style="{ color: item.category?.color ?? '#087f72', background: item.category?.tint ?? '#e9f7f3' }"
:style="{ color: item.color, background: item.tint }"
>
<component :is="item.category?.icon ?? WalletCards" :size="19" />
<component :is="item.icon" :size="19" />
</span>
<div>
<strong>{{ item.category?.label ?? "未分类" }}</strong>
<i :style="{ width: `${(item.amount / maxCategoryAmount) * 100}%`, background: item.category?.color ?? '#087f72' }"></i>
<strong>{{ item.label }}</strong>
<i :style="{ width: `${(item.amount / maxCategoryAmount) * 100}%`, background: item.color }"></i>
</div>
<span><strong>{{ money(item.amount) }}</strong><small>{{ item.count }} </small></span>
<button class="category-stat-filter" type="button" aria-label="查看该分类流水" title="查看该分类流水" @click.stop="openCategoryEntries(item)"><ChevronRight :size="16" /></button>
</div>
</section>
@ -255,12 +612,28 @@ function dailyLabel(value: string) {
<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 { height:74px; display:grid; grid-template-columns:40px 1fr auto; align-items:center; gap:8px; padding:14px 18px 8px; background:var(--ledger-gradient, #087f72); color:#fff; }
.stats-back-button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:rgba(255,255,255,.14); 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-trigger { width:100%; min-height:44px; display:flex; align-items:center; justify-content:space-between; border:1px solid #d2dfdc; border-radius:8px; padding:0 12px; background:#fff; color:#344640; font-size:14px; text-align:left; }
.stats-range-modal .stats-range-modes { margin:12px 0; }
.stats-picker-year-nav { display:grid; grid-template-columns:38px 1fr 38px; align-items:center; gap:4px; min-height:52px; }
.stats-picker-year-nav button { min-height:38px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#536762; font-size:19px; font-variant-numeric:tabular-nums; }
.stats-picker-year-nav button.selected { background:#eaf7f3; color:#087f72; font-weight:720; }
.stats-month-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.stats-month-grid button { min-height:52px; border:1px solid #e1ebe8; border-radius:8px; background:#fff; color:#344640; font-size:14px; }
.stats-month-grid button.selected { border-color:#087f72; background:#eaf7f3; color:#087f72; box-shadow:inset 0 0 0 1px #087f72; font-weight:720; }
.stats-year-list { display:grid; gap:2px; padding-top:8px; }
.stats-year-list button { min-height:54px; border:0; border-bottom:1px solid #e5edeb; background:#fff; color:#344640; font-size:15px; text-align:left; }
.stats-year-list button.selected { color:#087f72; font-weight:720; }
.stats-custom-modal { margin-top:12px; }
.stats-all-range { margin-top:12px; border:1px solid #d2dfdc; border-radius:8px; padding:11px 10px; background:#f8fbfa; color:#536762; font-size:12px; text-align:center; }
.stats-range-confirm { grid-column:1 / -1; min-height:44px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.stats-range-confirm:disabled { opacity:.5; }
.stats-range-modes { display:grid; grid-template-columns:repeat(4,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; }
@ -286,6 +659,43 @@ function dailyLabel(value: string) {
.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-controls { display:flex; align-items:center; gap:6px; }
.daily-granularity-switch { display:flex; gap:2px; border-radius:6px; padding:3px; background:#e7eeec; }
.daily-granularity-switch button { min-width:25px; height:26px; border:0; border-radius:4px; background:transparent; color:#71807c; font-size:10px; }
.daily-granularity-switch button.active { background:#fff; color:#087f72; box-shadow:0 1px 4px rgba(28,52,47,.12); }
.daily-view-switch { display:flex; gap:3px; border-radius:6px; padding:3px; background:#e7eeec; }
.daily-view-switch button { width:28px; height:26px; display:grid; place-items:center; border:0; border-radius:4px; background:transparent; color:#71807c; }
.daily-view-switch button.active { background:#fff; color:#087f72; box-shadow:0 1px 4px rgba(28,52,47,.12); }
.daily-calendar { padding:12px; }
.calendar-month-nav { display:grid; grid-template-columns:30px 1fr 30px; align-items:center; margin-bottom:9px; }
.calendar-month-nav button { width:30px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:#eef4f2; color:#58716a; }
.calendar-month-nav strong { color:#536762; font-size:12px; font-weight:720; text-align:center; }
.calendar-month-label { margin-bottom:9px; color:#536762; font-size:12px; font-weight:720; text-align:center; }
.calendar-weekdays,.calendar-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:4px; }
.calendar-weekdays { margin-bottom:4px; color:#8a9994; font-size:10px; text-align:center; }
.calendar-cell { min-width:0; min-height:58px; display:grid; align-content:start; justify-items:center; gap:3px; border:1px solid #e6eeec; border-radius:6px; padding:4px 2px; background:#fff; }
.calendar-cell.blank { border-color:transparent; background:transparent; }
.calendar-cell b { color:#536762; font-size:11px; font-weight:680; }
.calendar-cell i { width:0; max-width:80%; height:3px; display:block; border-radius:2px; }
.calendar-cell i.income { background:#20a57d; }
.calendar-cell i.expense { background:#ef6a4d; }
.calendar-cell small { color:#7b8884; font-size:8px; 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; }
.line-chart-wrap { min-width:0; }
.line-chart-wrap svg { width:100%; height:auto; overflow:visible; }
.chart-axis-line { stroke:#aebdb8; stroke-width:1.2; }
.chart-grid-line { stroke:#e6eeec; stroke-width:1; }
.chart-axis-label { fill:#8a9994; font-size:9px; text-anchor:start; }
.chart-line { fill:none; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; }
.income-line { stroke:#20a57d; }
.expense-line { stroke:#ef6a4d; }
.chart-dot { stroke:#fff; stroke-width:2; }
.income-dot { fill:#20a57d; }
.expense-dot { fill:#ef6a4d; }
.line-chart-labels { display:flex; justify-content:space-between; color:#8a9994; font-size:9px; }
.daily-view-empty { min-height:180px; display:grid; place-items:center; color:#7b8884; font-size:12px; }
.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; }
@ -297,14 +707,35 @@ function dailyLabel(value: string) {
.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 { width:100%; min-height:64px; display:grid; grid-template-columns:18px 38px minmax(0,1fr) auto 28px; align-items:center; gap:8px; border-bottom:1px solid #edf2f0; padding:8px 12px 8px calc(12px + var(--category-indent, 0px)); background:#fff; color:#26342f; text-align:left; cursor:pointer; }
.category-stat-row:last-child { border-bottom: 0; }
.category-stat-row:not(.expandable) { cursor:default; }
.category-stat-row.expandable:active { background:#f4f9f7; }
.category-stat-row.level-0 { min-height:68px; gap:10px; padding-top:10px; padding-bottom:10px; }
.category-stat-row.level-1 { min-height:56px; grid-template-columns:18px 34px minmax(0,1fr) auto 28px; gap:7px; padding-top:7px; padding-bottom:7px; background:#fbfdfc; }
.category-stat-row.level-2 { min-height:46px; grid-template-columns:18px 30px minmax(0,1fr) auto 28px; gap:5px; padding-top:5px; padding-bottom:5px; background:#fff; }
.category-stat-row.level-0 .category-stat-icon { width:38px; height:38px; }
.category-stat-row.level-0 .category-stat-icon :deep(svg) { width:22px; height:22px; }
.category-stat-row.level-0 > div strong { font-size:15px; }
.category-stat-row.level-0 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:14px; }
.category-stat-row.level-1 .category-stat-icon { width:32px; height:32px; }
.category-stat-row.level-1 .category-stat-icon :deep(svg) { width:19px; height:19px; }
.category-stat-row.level-1 > div strong { font-size:13px; }
.category-stat-row.level-1 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:12px; }
.category-stat-row.level-2 .category-stat-icon { width:28px; height:28px; }
.category-stat-row.level-2 .category-stat-icon :deep(svg) { width:16px; height:16px; }
.category-stat-row.level-2 > div strong { font-size:11px; }
.category-stat-row.level-2 > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size:11px; }
.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 > span:not(.category-stat-icon):not(.category-stat-chevron) { display: grid; text-align: right; }
.category-stat-row > span:not(.category-stat-icon):not(.category-stat-chevron) strong { font-size: 12px; }
.category-stat-filter { width:28px; height:32px; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8a9994; }
.category-stat-filter:active { background:#eaf7f3; color:#087f72; }
.category-stat-expand-indicator { width:18px; height:24px; display:grid; place-items:center; color:#087f72; }
.category-stat-row:not(.expandable) .category-stat-expand-indicator { color:transparent; }
.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; }

View File

@ -64,10 +64,43 @@ export const ledgerThemeAccents = {
sky: "#327ba0",
olive: "#64784a",
midnight: "#47577f",
mint: "#3c9b83",
plum: "#895c9c",
ember: "#b86545",
aqua: "#258fa3",
rose: "#c6536f",
forest: "#4b7f5c",
} as const;
export type LedgerThemeId = keyof typeof ledgerThemeAccents;
export const ledgerIconKeys = [
"wallet",
"house",
"shopping-bag",
"shopping-cart",
"utensils",
"luggage",
"map",
"baby",
"milk",
"heart",
"graduation-cap",
"toy-brick",
"party-popper",
"cake",
"gift",
"users",
"paint-bucket",
"hammer",
"sofa",
"book-open",
"car",
"wrench",
] as const;
export type LedgerIconKey = typeof ledgerIconKeys[number];
export type LedgerAmountDisplayMode = "base" | "original";
export type LedgerEntry = {

View File

@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS ledgers (
name varchar(40) NOT NULL,
color varchar(16) NOT NULL DEFAULT '#087f72',
theme varchar(24) NOT NULL DEFAULT 'jade',
icon varchar(32) NOT NULL DEFAULT 'wallet',
default_currency varchar(3) NOT NULL DEFAULT 'CNY',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
@ -35,6 +36,7 @@ CREATE TABLE IF NOT EXISTS ledgers (
);
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS theme varchar(24) NOT NULL DEFAULT 'jade';
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS icon varchar(32) NOT NULL DEFAULT 'wallet';
UPDATE ledgers SET theme = CASE lower(color)
WHEN '#3478e5' THEN 'ocean'
WHEN '#7559d9' THEN 'berry'

View File

@ -3,6 +3,7 @@ import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import {
ledgerThemeAccents,
ledgerIconKeys,
type CurrencyCode,
type LedgerEntry,
type LedgerThemeId,
@ -74,6 +75,10 @@ function uniqueViolation(error: unknown) {
return typeof error === "object" && error !== null && "code" in error && error.code === "23505";
}
function validLedgerIcon(value: string): value is typeof ledgerIconKeys[number] {
return (ledgerIconKeys as readonly string[]).includes(value);
}
async function currentUser(request: FastifyRequest, reply?: FastifyReply): Promise<User | null> {
const token = request.cookies[sessionCookieName];
if (!token) return null;
@ -266,7 +271,7 @@ server.get("/api/ledgers", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
const result = await pool.query(
`SELECT l.id, l.name, l.color, l.theme, l.default_currency AS "defaultCurrency",
`SELECT l.id, l.name, l.color, l.theme, l.icon, l.default_currency AS "defaultCurrency",
l.created_at AS "createdAt", l.updated_at AS "updatedAt",
l.archived_at AS "archivedAt", m.role,
(l.personal_owner_id IS NOT NULL) AS "isPersonal"
@ -278,7 +283,7 @@ server.get("/api/ledgers", async (request, reply) => {
return { ledgers: result.rows };
});
server.post<{ Body: { name?: string; color?: string; theme?: string; defaultCurrency?: string } }>(
server.post<{ Body: { name?: string; color?: string; theme?: string; icon?: string; defaultCurrency?: string } }>(
"/api/ledgers",
async (request, reply) => {
const user = await requireUser(request, reply);
@ -287,8 +292,12 @@ server.post<{ Body: { name?: string; color?: string; theme?: string; defaultCurr
if (request.body.theme !== undefined && !validLedgerTheme(request.body.theme)) {
return reply.code(400).send({ error: "账本主题无效" });
}
if (request.body.icon !== undefined && !validLedgerIcon(request.body.icon)) {
return reply.code(400).send({ error: "账本图标无效" });
}
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
const color = ledgerThemeAccents[theme];
const icon = request.body.icon ?? "wallet";
const defaultCurrency = request.body.defaultCurrency ?? "CNY";
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称应为 1 至 40 个字符" });
if (!/^#[0-9a-f]{6}$/i.test(color)) return reply.code(400).send({ error: "账本颜色无效" });
@ -301,11 +310,11 @@ server.post<{ Body: { name?: string; color?: string; theme?: string; defaultCurr
await client.query("BEGIN");
const ledgerId = createId();
const result = await client.query(
`INSERT INTO ledgers (id, name, color, theme, default_currency)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
`INSERT INTO ledgers (id, name, color, theme, icon, default_currency)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, color, theme, icon, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[ledgerId, name, color, theme, defaultCurrency],
[ledgerId, name, color, theme, icon, defaultCurrency],
);
await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
@ -338,15 +347,16 @@ server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", a
server.patch<{
Params: { ledgerId: string };
Body: { name?: string; color?: string; theme?: string; defaultCurrency?: string };
Body: { name?: string; color?: string; theme?: string; icon?: string; defaultCurrency?: string };
}>("/api/ledgers/:ledgerId", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
return reply.code(403).send({ error: "只有账本拥有者可以修改设置" });
}
const currentLedger = await pool.query<{ isPersonal: boolean; defaultCurrency: string }>(
const currentLedger = await pool.query<{ isPersonal: boolean; defaultCurrency: string; icon: string }>(
`SELECT (personal_owner_id IS NOT NULL) AS "isPersonal",
icon,
default_currency AS "defaultCurrency"
FROM ledgers WHERE id = $1`,
[request.params.ledgerId],
@ -357,18 +367,22 @@ server.patch<{
if (request.body.theme !== undefined && !validLedgerTheme(request.body.theme)) {
return reply.code(400).send({ error: "账本主题无效" });
}
if (request.body.icon !== undefined && !validLedgerIcon(request.body.icon)) {
return reply.code(400).send({ error: "账本图标无效" });
}
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
const color = ledgerThemeAccents[theme];
const icon = request.body.icon ?? currentLedger.rows[0].icon;
const defaultCurrency = currentLedger.rows[0].defaultCurrency;
if (request.body.defaultCurrency !== undefined && request.body.defaultCurrency !== defaultCurrency) {
return reply.code(400).send({ error: "默认币种暂不可修改" });
}
const result = await pool.query(
`UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
WHERE id = $5
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
`UPDATE ledgers SET name = $1, color = $2, theme = $3, icon = $4, default_currency = $5, updated_at = now()
WHERE id = $6
RETURNING id, name, color, theme, icon, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[currentLedger.rows[0].isPersonal ? PERSONAL_LEDGER_NAME : name, color, theme, defaultCurrency, request.params.ledgerId],
[currentLedger.rows[0].isPersonal ? PERSONAL_LEDGER_NAME : name, color, theme, icon, defaultCurrency, request.params.ledgerId],
);
return { ledger: result.rows[0] };
});