Allow entries to span multiple ledgers

This commit is contained in:
openclaw 2026-07-22 00:48:29 +08:00
parent d631bb40d8
commit 50dfafc1d2
14 changed files with 335 additions and 101 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon } from "@lucide/vue";
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon, X } from "@lucide/vue";
import { computed, ref } from "vue";
import {
categories,
@ -15,20 +15,20 @@ type SavedEntry = {
categoryId: string;
note: string;
occurredAt: string;
ledgerIds: string[];
};
type SelectorLevel = "type" | "category" | "subcategory";
defineProps<{
const props = defineProps<{
ledgerId: string;
ledgerName: string;
ledgerOptions: Array<{ id: string; name: string }>;
ledgerOptions: Array<{ id: string; name: string; color: string }>;
}>();
const emit = defineEmits<{
close: [];
save: [entry: SavedEntry];
changeLedger: [ledgerId: string];
}>();
const expression = ref("0");
@ -40,6 +40,8 @@ const dateInput = ref<HTMLInputElement | null>(null);
const selectorOpen = ref(false);
const selectorLevel = ref<SelectorLevel>("type");
const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const selectedLedgerIds = ref([props.ledgerId]);
const axisOffset = ref(0);
let axisPointerId: number | null = null;
let axisDragStartY = 0;
@ -65,6 +67,9 @@ const displayAmount = computed(() =>
maximumFractionDigits: 2,
}).format(calculatedAmount.value),
);
const selectedLedgerLabel = computed(() => selectedLedgerIds.value.length === 1
? props.ledgerOptions.find((ledger) => ledger.id === selectedLedgerIds.value[0])?.name ?? props.ledgerName
: `${selectedLedgerIds.value.length} 个账本`);
const dateLabel = computed(() => {
const date = new Date(occurredAt.value);
const now = new Date();
@ -315,6 +320,22 @@ function startNoteEditing() {
noteEditing.value = true;
}
function openLedgerPicker() {
confirmCategory();
noteEditing.value = false;
ledgerPickerOpen.value = true;
}
function toggleLedger(ledgerId: string) {
if (selectedLedgerIds.value.includes(ledgerId)) {
if (selectedLedgerIds.value.length > 1) {
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
}
return;
}
selectedLedgerIds.value = [...selectedLedgerIds.value, ledgerId];
}
function saveEntry() {
const amount = Math.round(calculatedAmount.value * 100);
if (!Number.isFinite(amount) || amount <= 0) return;
@ -325,6 +346,7 @@ function saveEntry() {
categoryId: selectedCategory.value?.id ?? entryType.value,
note: note.value.trim(),
occurredAt: new Date(occurredAt.value).toISOString(),
ledgerIds: selectedLedgerIds.value,
});
}
</script>
@ -342,18 +364,11 @@ function saveEntry() {
<strong class="amount-value">{{ displayAmount }}</strong>
</div>
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
<label class="quick-ledger-target">
<button class="quick-ledger-target" type="button" @click="openLedgerPicker">
<BookOpen :size="13" />
<span>记入 {{ ledgerName }}</span>
<span>记入 {{ selectedLedgerLabel }}</span>
<ChevronDown :size="13" />
<select
:value="ledgerId"
aria-label="记入账本"
@change="emit('changeLedger', ($event.target as HTMLSelectElement).value)"
>
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.name }}</option>
</select>
</label>
</button>
</div>
<button class="header-date-button" type="button" @click="openDatePicker">
<CalendarDays :size="16" />
@ -503,4 +518,20 @@ function saveEntry() {
</button>
</div>
</section>
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
<section class="quick-ledger-picker" aria-label="选择关联账本">
<div class="quick-ledger-picker-handle"></div>
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
<div class="quick-ledger-options">
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
<strong>{{ ledger.name }}</strong>
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
</button>
</div>
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
</section>
</div>
</template>

View File

@ -16,29 +16,25 @@ const drawerOpen = ref(false);
const savedToast = ref(false);
const ledgerOptions = computed(() =>
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name })),
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color })),
);
onMounted(async () => {
if (!ledgerStore.loaded) await ledgerStore.loadLedgers();
});
async function changeLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
}
async function saveEntry(input: {
type: "expense" | "income";
amount: number;
categoryId: string;
note: string;
occurredAt: string;
ledgerIds: string[];
}) {
const ledger = ledgerStore.currentLedger;
if (!ledger) return;
await entryStore.addEntry(createEntry({
...input,
ledgerId: ledger.id,
currency: ledger.defaultCurrency,
createdBy: authStore.user?.id,
}));
@ -67,7 +63,6 @@ async function saveEntry(input: {
:ledger-id="ledgerStore.currentLedger.id"
:ledger-name="ledgerStore.currentLedger.name"
:ledger-options="ledgerOptions"
@change-ledger="changeLedger"
@close="drawerOpen = false"
@save="saveEntry"
/>

View File

@ -35,3 +35,31 @@ db.version(3).stores({
userPreferences: "id, userId, key, updatedAt",
syncMetadata: "id, updatedAt",
});
type LegacyEntry = LedgerEntry & { ledgerId?: string; ownerId?: string; ledgerIds?: string[] };
type LegacyOperation = SyncOperation & { ledgerId?: string; ledgerIds?: string[]; payload: LegacyEntry };
db.version(4).stores({
entries: "id, ownerId, *ledgerIds, occurredAt, updatedAt, deletedAt",
syncOperations: "id, *ledgerIds, createdAt, syncedAt",
ledgers: "id, updatedAt, archivedAt",
userPreferences: "id, userId, key, updatedAt",
syncMetadata: "id, updatedAt",
}).upgrade(async (transaction) => {
await transaction.table<LegacyEntry>("entries").toCollection().modify((entry) => {
entry.ownerId ??= entry.createdBy;
entry.ledgerIds = entry.ledgerIds?.length ? [...new Set(entry.ledgerIds)] : entry.ledgerId ? [entry.ledgerId] : [];
delete entry.ledgerId;
});
await transaction.table<LegacyOperation>("syncOperations").toCollection().modify((operation) => {
operation.payload.ownerId ??= operation.payload.createdBy;
operation.payload.ledgerIds = operation.payload.ledgerIds?.length
? [...new Set(operation.payload.ledgerIds)]
: operation.payload.ledgerId ? [operation.payload.ledgerId] : [];
operation.ledgerIds = operation.ledgerIds?.length
? [...new Set(operation.ledgerIds)]
: operation.ledgerId ? [operation.ledgerId] : operation.payload.ledgerIds;
delete operation.payload.ledgerId;
delete operation.ledgerId;
});
});

View File

@ -2,7 +2,7 @@ import type { CurrencyCode, EntryType, LedgerEntry } from "@cents/domain";
import { createId } from "./ids";
type EntryInput = {
ledgerId: string;
ledgerIds: string[];
type: EntryType;
amount: number;
currency: CurrencyCode;
@ -19,7 +19,8 @@ export function createEntry(input: EntryInput): LedgerEntry {
return {
id: createId(),
ledgerId: input.ledgerId,
ownerId: input.createdBy ?? "local-user",
ledgerIds: input.ledgerIds,
type: input.type,
amount: input.amount,
currency: input.currency,
@ -49,10 +50,10 @@ export function createDemoEntries(): LedgerEntry[] {
};
return [
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 3150, currency: "CNY", categoryId: "taxi", note: "打车", occurredAt: at(0, 9, 16) }),
createEntry({ ledgerId: "local-ledger", type: "income", amount: 824000, currency: "CNY", categoryId: "salary", note: "工资", occurredAt: at(1, 18, 2) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 23680, currency: "CNY", categoryId: "groceries", note: "超市采购", occurredAt: at(1, 16, 45) }),
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }),
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 3150, currency: "CNY", categoryId: "taxi", note: "打车", occurredAt: at(0, 9, 16) }),
createEntry({ ledgerIds: ["local-ledger"], type: "income", amount: 824000, currency: "CNY", categoryId: "salary", note: "工资", occurredAt: at(1, 18, 2) }),
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 23680, currency: "CNY", categoryId: "groceries", note: "超市采购", occurredAt: at(1, 16, 45) }),
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
];
}

View File

@ -12,7 +12,7 @@ export const useEntryStore = defineStore("entries", {
state: () => ({
entries: [] as LedgerEntry[],
pendingEntryIds: [] as string[],
pendingEntries: [] as Array<{ id: string; ledgerId: string }>,
pendingEntries: [] as Array<{ id: string; ledgerIds: string[] }>,
syncing: false,
syncError: "",
lastSyncedAt: "" as string,
@ -30,14 +30,14 @@ export const useEntryStore = defineStore("entries", {
const pendingOperations = await db.syncOperations.filter((operation) => operation.syncedAt === null).toArray();
this.pendingEntryIds = [...new Set(pendingOperations.map((operation) => operation.entityId))];
this.pendingEntries = [...new Map(
pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerId: operation.ledgerId }]),
pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerIds: operation.ledgerIds }]),
).values()];
},
isEntryPending(entryId: string) {
return this.pendingEntryIds.includes(entryId);
},
pendingEntryCount(ledgerId: string) {
return this.pendingEntries.filter((entry) => entry.ledgerId === ledgerId).length;
return this.pendingEntries.filter((entry) => entry.ledgerIds.includes(ledgerId)).length;
},
startSyncTriggers() {
if (syncTriggersStarted || typeof window === "undefined") return;
@ -87,9 +87,15 @@ export const useEntryStore = defineStore("entries", {
.filter((operation) => operation.syncedAt === null)
.toArray();
const locallyChangedIds = new Set(remainingOperations.map((operation) => operation.entityId));
const safeCloudEntries = pulled.entries.filter((entry) => !locallyChangedIds.has(entry.id));
const safeCloudEntries = pulled.entries.filter(
(entry) => !locallyChangedIds.has(entry.id) && (entry.ownerId === userId || entry.ledgerIds.length > 0),
);
const inaccessibleEntryIds = pulled.entries
.filter((entry) => !locallyChangedIds.has(entry.id) && entry.ownerId !== userId && entry.ledgerIds.length === 0)
.map((entry) => entry.id);
await db.transaction("rw", db.entries, db.syncMetadata, async () => {
if (safeCloudEntries.length) await db.entries.bulkPut(safeCloudEntries);
if (inaccessibleEntryIds.length) await db.entries.bulkDelete(inaccessibleEntryIds);
if (pulled.cursor) {
await db.syncMetadata.put({ id: userId, cursor: pulled.cursor, updatedAt: new Date().toISOString() });
}
@ -116,7 +122,7 @@ export const useEntryStore = defineStore("entries", {
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: entry.ledgerId,
ledgerIds: entry.ledgerIds,
entity: "entry",
entityId: entry.id,
action: "create",
@ -148,7 +154,7 @@ export const useEntryStore = defineStore("entries", {
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: updated.ledgerId,
ledgerIds: updated.ledgerIds,
entity: "entry",
entityId: updated.id,
action: "update",
@ -182,7 +188,7 @@ export const useEntryStore = defineStore("entries", {
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: deleted.ledgerId,
ledgerIds: deleted.ledgerIds,
entity: "entry",
entityId: deleted.id,
action: "delete",

View File

@ -40,11 +40,20 @@ export const useLedgerStore = defineStore("ledgers", {
this.ledgers = serverLedgers;
const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id));
const inaccessibleEntries = await db.entries.filter((entry) => !allowedIds.has(entry.ledgerId)).primaryKeys();
const inaccessibleOperations = await db.syncOperations.filter((item) => !allowedIds.has(item.ledgerId)).primaryKeys();
const localEntries = await db.entries.toArray();
const inaccessibleEntries = localEntries
.filter((entry) => entry.ownerId !== auth.user!.id && !entry.ledgerIds.some((ledgerId) => allowedIds.has(ledgerId)))
.map((entry) => entry.id);
const projectedEntries = localEntries
.filter((entry) => entry.ownerId !== auth.user!.id && entry.ledgerIds.some((ledgerId) => !allowedIds.has(ledgerId)))
.map((entry) => ({ ...entry, ledgerIds: entry.ledgerIds.filter((ledgerId) => allowedIds.has(ledgerId)) }));
const inaccessibleOperations = await db.syncOperations
.filter((item) => !item.ledgerIds.some((ledgerId) => allowedIds.has(ledgerId)))
.primaryKeys();
await db.transaction("rw", db.ledgers, db.entries, db.syncOperations, async () => {
await db.ledgers.clear();
if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers);
if (projectedEntries.length) await db.entries.bulkPut(projectedEntries);
if (inaccessibleEntries.length) await db.entries.bulkDelete(inaccessibleEntries);
if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations);
});

View File

@ -672,6 +672,7 @@ input:focus-visible {
color: #087f72;
font-size: 11px;
font-weight: 680;
border: 0;
}
.quick-ledger-target span {
@ -680,13 +681,22 @@ input:focus-visible {
white-space: nowrap;
}
.quick-ledger-target select {
position: absolute;
inset: 0;
width: 100%;
opacity: 0;
cursor: pointer;
}
.quick-ledger-picker-layer { position:absolute; inset:0; z-index:30; }
.quick-ledger-picker-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.42); backdrop-filter:blur(2px); }
.quick-ledger-picker { position:absolute; right:0; bottom:0; left:0; max-height:90%; overflow-y:auto; border-radius:14px 14px 0 0; padding:0 16px calc(16px + env(safe-area-inset-bottom)); background:#fff; color:#26342f; }
.quick-ledger-picker-handle { width:38px; height:4px; margin:8px auto 3px; border-radius:2px; background:#b9cac5; }
.quick-ledger-picker > header { min-height:56px; display:flex; align-items:center; justify-content:space-between; }
.quick-ledger-picker > header > div { display:grid; gap:1px; }
.quick-ledger-picker > header strong { font-size:16px; }
.quick-ledger-picker > header span { color:#7b8884; font-size:11px; }
.quick-ledger-picker > header > button { width:36px; height:36px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
.quick-ledger-options { border-top:1px solid #e1ebe8; border-bottom:1px solid #e1ebe8; }
.quick-ledger-options > button { width:100%; min-height:54px; display:grid; grid-template-columns:36px minmax(0,1fr) 24px; align-items:center; gap:10px; border:0; border-bottom:1px solid #edf2f0; padding:7px 2px; background:#fff; color:#26342f; text-align:left; }
.quick-ledger-options > button:last-child { border-bottom:0; }
.quick-ledger-options > button > span { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; color:#fff; }
.quick-ledger-options > button > strong { overflow:hidden; font-size:14px; text-overflow:ellipsis; white-space:nowrap; }
.quick-ledger-options > button > svg { color:#087f72; }
.quick-ledger-picker-done { width:100%; height:46px; display:flex; align-items:center; justify-content:center; gap:6px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.amount-result {
min-width: 0;

View File

@ -5,7 +5,6 @@ import {
BookOpen,
CalendarDays,
Check,
ChevronDown,
ChevronRight,
CircleUserRound,
CloudOff,
@ -38,10 +37,11 @@ const saving = ref(false);
const savedToast = ref(false);
const errorMessage = ref("");
const categorySheetMode = ref<CategorySheetMode | null>(null);
const ledgerPickerOpen = ref(false);
const dateInput = ref<HTMLInputElement | null>(null);
const amount = ref("");
const ledgerId = ref("local-ledger");
const ledgerIds = ref<string[]>([]);
const parentCategoryId = ref("");
const childCategoryId = ref("");
const note = ref("");
@ -78,7 +78,9 @@ const timeInvalid = computed(() => {
return Number.isNaN(new Date(occurredAt.value).getTime());
});
const ledgerLabel = computed(() =>
ledgerOptions.value.find((ledger) => ledger.id === ledgerId.value)?.label ?? "未知账本",
ledgerIds.value.length === 1
? ledgerOptions.value.find((ledger) => ledger.id === ledgerIds.value[0])?.label ?? "未知账本"
: `${ledgerIds.value.length} 个账本`,
);
const createdAtLabel = computed(() => {
if (!entry.value) return "";
@ -102,7 +104,7 @@ onMounted(async () => {
function hydrate(value: LedgerEntry) {
entry.value = value;
amount.value = (value.amount / 100).toFixed(2);
ledgerId.value = value.ledgerId;
ledgerIds.value = [...value.ledgerIds];
note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
const path = findCategoryPath(value.type, value.categoryId);
@ -134,6 +136,14 @@ function chooseChild(category: Category) {
categorySheetMode.value = null;
}
function toggleLedger(ledgerId: string) {
if (ledgerIds.value.includes(ledgerId)) {
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
return;
}
ledgerIds.value = [...ledgerIds.value, ledgerId];
}
async function saveEntry() {
if (!entry.value || saving.value) return;
const numericAmount = Number(amount.value.replaceAll(",", ""));
@ -159,7 +169,7 @@ async function saveEntry() {
...entry.value,
amount: cents,
baseAmount: Math.round(cents * exchangeRate),
ledgerId: ledgerId.value,
ledgerIds: ledgerIds.value,
categoryId: childCategoryId.value || parentCategoryId.value,
note: note.value.trim(),
occurredAt: occurredDate.toISOString(),
@ -250,18 +260,15 @@ async function deleteEntry() {
tabindex="-1"
/>
<label class="entry-edit-row entry-select-row">
<button class="entry-edit-row" type="button" @click="ledgerPickerOpen = true">
<span class="entry-field-icon ledger"><BookOpen :size="19" /></span>
<span class="entry-field-copy"><small>账本</small><strong>{{ ledgerLabel }}</strong></span>
<select v-model="ledgerId" aria-label="账本">
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.label }}</option>
</select>
<ChevronDown :size="18" />
</label>
<ChevronRight :size="18" />
</button>
<div class="entry-edit-row readonly-row">
<span class="entry-field-icon member"><CircleUserRound :size="19" /></span>
<span class="entry-field-copy"><small>记录人</small><strong>小王</strong></span>
<span class="entry-field-copy"><small>记录人</small><strong>{{ ledgerStore.memberName(entry.createdBy) }}</strong></span>
</div>
<label class="entry-note-row">
@ -333,6 +340,22 @@ async function deleteEntry() {
</div>
</Transition>
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
<section class="quick-ledger-picker" aria-label="选择关联账本">
<div class="quick-ledger-picker-handle"></div>
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
<div class="quick-ledger-options">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" :class="{ selected: ledgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
<strong>{{ ledger.name }}</strong>
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
</button>
</div>
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
</section>
</div>
<Transition name="toast">
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
</Transition>

View File

@ -41,7 +41,7 @@ const monthlyEntries = computed(() => {
const current = new Date();
return store.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerId === ledgerStore.currentLedgerId
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
&& occurredAt.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth();
});

View File

@ -21,7 +21,9 @@ const errorMessage = ref("");
const entryCounts = computed(() => {
const counts = new Map<string, number>();
for (const entry of entryStore.entries) counts.set(entry.ledgerId, (counts.get(entry.ledgerId) ?? 0) + 1);
for (const entry of entryStore.entries) {
for (const ledgerId of entry.ledgerIds) counts.set(ledgerId, (counts.get(ledgerId) ?? 0) + 1);
}
return counts;
});

View File

@ -5,15 +5,19 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories";
import { useAuthStore } from "../stores/auth";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
type StatsTab = "flow" | "category" | "member";
type StatsScope = "ledger" | "personal";
const router = useRouter();
const authStore = useAuthStore();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const activeTab = ref<StatsTab>("flow");
const scope = ref<StatsScope>("ledger");
const monthLabel = computed(() => {
const date = new Date();
@ -24,7 +28,10 @@ const entries = computed(() => {
const current = new Date();
return entryStore.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt);
return entry.ledgerId === ledgerStore.currentLedgerId
const inScope = scope.value === "personal"
? entry.ownerId === authStore.user?.id
: entry.ledgerIds.includes(ledgerStore.currentLedgerId);
return inScope
&& occurredAt.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth();
});
@ -107,6 +114,11 @@ function money(value: number) {
<div class="stats-scroll">
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button>
<div class="stats-scope" role="group" aria-label="统计范围">
<button type="button" :class="{ active: scope === 'ledger' }" @click="scope = 'ledger'">当前账本</button>
<button type="button" :class="{ active: scope === 'personal' }" @click="scope = 'personal'">我的全部</button>
</div>
<section class="stats-summary" aria-label="本月统计">
<div>
<span>结余</span>
@ -174,6 +186,9 @@ function money(value: number) {
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; }
.stats-scope { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:0 0 12px; border-radius:8px; padding:4px; background:#e7eeec; }
.stats-scope button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
.stats-scope button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; }
.stats-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
.stats-summary > div:first-child { border-left: 0; }

View File

@ -6,7 +6,8 @@ export type ExchangeRateSource = "manual" | "system";
export type LedgerEntry = {
id: string;
ledgerId: string;
ownerId: string;
ledgerIds: string[];
type: EntryType;
amount: number;
currency: CurrencyCode;
@ -30,7 +31,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
export type SyncOperation = {
id: string;
userId?: string;
ledgerId: string;
ledgerIds: string[];
entity: "entry";
entityId: string;
action: SyncOperationAction;

View File

@ -84,7 +84,7 @@ CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
CREATE TABLE IF NOT EXISTS entries (
id text PRIMARY KEY,
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
owner_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
amount integer NOT NULL CHECK (amount > 0),
currency varchar(3) NOT NULL,
@ -104,8 +104,41 @@ CREATE TABLE IF NOT EXISTS entries (
server_updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS entries_ledger_occurred
ON entries (ledger_id, occurred_at DESC);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
UPDATE entries SET owner_id = COALESCE(created_by, updated_by) WHERE owner_id IS NULL;
ALTER TABLE entries ALTER COLUMN owner_id SET NOT NULL;
CREATE TABLE IF NOT EXISTS entry_ledgers (
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
linked_at timestamptz NOT NULL DEFAULT now(),
unlinked_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (entry_id, ledger_id)
);
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS unlinked_at timestamptz;
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'entries' AND column_name = 'ledger_id'
) THEN
EXECUTE 'INSERT INTO entry_ledgers (entry_id, ledger_id)
SELECT id, ledger_id FROM entries
ON CONFLICT (entry_id, ledger_id) DO NOTHING';
END IF;
END $$;
DROP INDEX IF EXISTS entries_ledger_occurred;
ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
CREATE INDEX IF NOT EXISTS entries_owner_occurred
ON entries (owner_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
ON entry_ledgers (ledger_id, entry_id);
CREATE TABLE IF NOT EXISTS entry_sync_operations (
id text PRIMARY KEY,

View File

@ -120,11 +120,18 @@ function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function validLedgerIds(value: unknown): value is string[] {
return Array.isArray(value) && value.length > 0 && value.length <= 20
&& value.every((ledgerId) => typeof ledgerId === "string" && ledgerId.length > 0 && ledgerId.length <= 100)
&& new Set(value).size === value.length;
}
function validEntry(value: unknown): value is LedgerEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<LedgerEntry>;
return typeof entry.id === "string" && entry.id.length > 0 && entry.id.length <= 100
&& typeof entry.ledgerId === "string" && entry.ledgerId.length > 0 && entry.ledgerId.length <= 100
&& typeof entry.ownerId === "string" && entry.ownerId.length > 0 && entry.ownerId.length <= 100
&& validLedgerIds(entry.ledgerIds)
&& (entry.type === "expense" || entry.type === "income")
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
&& typeof entry.currency === "string" && currencies.has(entry.currency)
@ -142,13 +149,16 @@ function validEntry(value: unknown): value is LedgerEntry {
function validSyncOperation(value: unknown): value is SyncOperation {
if (!value || typeof value !== "object") return false;
const operation = value as Partial<SyncOperation>;
const payload = operation.payload;
return typeof operation.id === "string" && operation.id.length > 0 && operation.id.length <= 100
&& operation.entity === "entry"
&& (operation.action === "create" || operation.action === "update" || operation.action === "delete")
&& validEntry(operation.payload)
&& operation.entityId === operation.payload.id
&& operation.ledgerId === operation.payload.ledgerId
&& (operation.action !== "delete" || operation.payload.deletedAt !== null);
&& validEntry(payload)
&& operation.entityId === payload.id
&& validLedgerIds(operation.ledgerIds)
&& operation.ledgerIds.length === payload.ledgerIds.length
&& operation.ledgerIds.every((ledgerId) => payload.ledgerIds.includes(ledgerId))
&& (operation.action !== "delete" || payload.deletedAt !== null);
}
type SyncCursor = { serverUpdatedAt: string; id: string };
@ -440,7 +450,11 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
[invitation!.ledgerId, user.id, invitation!.role],
);
await client.query(
"UPDATE entries SET server_updated_at = now() WHERE ledger_id = $1",
`UPDATE entries e SET server_updated_at = now()
WHERE EXISTS (
SELECT 1 FROM entry_ledgers el
WHERE el.entry_id = e.id AND el.ledger_id = $1 AND el.unlinked_at IS NULL
)`,
[invitation!.ledgerId],
);
await client.query(
@ -479,35 +493,43 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
continue;
}
const targetRole = await client.query(
`SELECT role FROM ledger_members
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
[operation.ledgerId, user.id],
const existing = await client.query<{ ownerId: string; canAccess: boolean }>(
`SELECT e.owner_id AS "ownerId",
(e.owner_id = $2 OR EXISTS (
SELECT 1 FROM entry_ledgers el
JOIN ledger_members m ON m.ledger_id = el.ledger_id
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
AND m.user_id = $2 AND m.removed_at IS NULL
)) AS "canAccess"
FROM entries e WHERE e.id = $1`,
[operation.entityId, user.id],
);
if (!targetRole.rowCount) {
if (existing.rows[0] && !existing.rows[0].canAccess) {
await client.query("ROLLBACK");
return reply.code(403).send({ error: "无权同步该账本的流水" });
return reply.code(403).send({ error: "无权修改该流水" });
}
const existing = await client.query<{ ledgerId: string }>(
`SELECT ledger_id AS "ledgerId" FROM entries WHERE id = $1`,
[operation.entityId],
const inaccessibleTargets = await client.query<{ count: number }>(
`SELECT count(*)::int AS count
FROM unnest($1::text[]) AS target(ledger_id)
WHERE NOT EXISTS (
SELECT 1 FROM ledger_members m
WHERE m.ledger_id = target.ledger_id AND m.user_id = $2 AND m.removed_at IS NULL
)
AND NOT ($3::boolean AND EXISTS (
SELECT 1 FROM entry_ledgers el
WHERE el.entry_id = $4 AND el.ledger_id = target.ledger_id AND el.unlinked_at IS NULL
))`,
[operation.ledgerIds, user.id, existing.rows[0]?.ownerId === user.id, operation.entityId],
);
if (existing.rows[0] && existing.rows[0].ledgerId !== operation.ledgerId) {
const sourceRole = await client.query(
`SELECT role FROM ledger_members
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
[existing.rows[0].ledgerId, user.id],
);
if (!sourceRole.rowCount) {
await client.query("ROLLBACK");
return reply.code(403).send({ error: "无权移动该流水" });
}
if (inaccessibleTargets.rows[0]?.count) {
await client.query("ROLLBACK");
return reply.code(403).send({ error: "无权关联其中一个账本" });
}
const entry = operation.payload;
await client.query(
const writeResult = await client.query(
`INSERT INTO entries (
id, ledger_id, type, amount, currency, base_currency, base_amount,
id, owner_id, type, amount, currency, base_currency, base_amount,
exchange_rate, exchange_rate_source, category_id, note, occurred_at,
created_by, updated_by, created_at, updated_at, deleted_at, version
) VALUES (
@ -515,7 +537,6 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
$13, $14, $15, $16, $17, $18
)
ON CONFLICT (id) DO UPDATE SET
ledger_id = EXCLUDED.ledger_id,
type = EXCLUDED.type,
amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
@ -531,15 +552,41 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
deleted_at = EXCLUDED.deleted_at,
version = EXCLUDED.version,
server_updated_at = now()
WHERE entries.version < EXCLUDED.version
WHERE entries.version < EXCLUDED.version
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`,
[
entry.id, entry.ledgerId, entry.type, entry.amount, entry.currency, entry.baseCurrency,
entry.id, user.id, entry.type, entry.amount, entry.currency, entry.baseCurrency,
entry.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version,
],
);
if (operation.action !== "delete" && writeResult.rowCount) {
if (existing.rows[0]?.ownerId === user.id) {
await client.query(
`UPDATE entry_ledgers SET unlinked_at = now(), updated_at = now()
WHERE entry_id = $1 AND unlinked_at IS NULL
AND NOT (ledger_id = ANY($2::text[]))`,
[entry.id, operation.ledgerIds],
);
} else if (existing.rows[0]) {
await client.query(
`UPDATE entry_ledgers el SET unlinked_at = now(), updated_at = now()
FROM ledger_members m
WHERE el.entry_id = $1 AND el.unlinked_at IS NULL AND el.ledger_id = m.ledger_id
AND m.user_id = $2 AND m.removed_at IS NULL
AND NOT (el.ledger_id = ANY($3::text[]))`,
[entry.id, user.id, operation.ledgerIds],
);
}
await client.query(
`INSERT INTO entry_ledgers (entry_id, ledger_id)
SELECT $1, unnest($2::text[])
ON CONFLICT (entry_id, ledger_id) DO UPDATE
SET unlinked_at = NULL, updated_at = now()`,
[entry.id, operation.ledgerIds],
);
}
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
await client.query(
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)",
@ -563,20 +610,53 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
const cursor = request.query.cursor ? decodeSyncCursor(request.query.cursor) : null;
if (request.query.cursor && !cursor) return reply.code(400).send({ error: "同步游标无效" });
const result = await pool.query<LedgerEntry & { serverUpdatedAt: Date }>(
`SELECT e.id, e.ledger_id AS "ledgerId", e.type, e.amount,
`SELECT e.id, e.owner_id AS "ownerId",
ARRAY(
SELECT el.ledger_id
FROM entry_ledgers el
LEFT JOIN ledger_members visible_member
ON visible_member.ledger_id = el.ledger_id
AND visible_member.user_id = $1
AND visible_member.removed_at IS NULL
WHERE el.entry_id = e.id
AND el.unlinked_at IS NULL
AND (e.owner_id = $1 OR visible_member.user_id IS NOT NULL)
ORDER BY el.ledger_id
) AS "ledgerIds",
e.type, e.amount,
e.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource",
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
e.deleted_at AS "deletedAt", e.version,
e.server_updated_at AS "serverUpdatedAt"
FROM entries e JOIN ledger_members m ON m.ledger_id = e.ledger_id
WHERE m.user_id = $1 AND m.removed_at IS NULL
entry_change.changed_at AS "serverUpdatedAt"
FROM entries e
CROSS JOIN LATERAL (
SELECT GREATEST(
e.server_updated_at,
COALESCE(MAX(el.updated_at) FILTER (
WHERE e.owner_id = $1 OR change_member.user_id IS NOT NULL
), e.server_updated_at)
) AS changed_at
FROM entry_ledgers el
LEFT JOIN ledger_members change_member
ON change_member.ledger_id = el.ledger_id
AND change_member.user_id = $1
AND change_member.removed_at IS NULL
WHERE el.entry_id = e.id
) entry_change
WHERE (e.owner_id = $1 OR EXISTS (
SELECT 1 FROM entry_ledgers accessible_link
JOIN ledger_members accessible_member ON accessible_member.ledger_id = accessible_link.ledger_id
WHERE accessible_link.entry_id = e.id
AND accessible_member.user_id = $1
AND accessible_member.removed_at IS NULL
))
AND ($2::timestamptz IS NULL
OR e.server_updated_at > $2::timestamptz
OR (e.server_updated_at = $2::timestamptz AND e.id > $3))
ORDER BY e.server_updated_at, e.id
OR entry_change.changed_at > $2::timestamptz
OR (entry_change.changed_at = $2::timestamptz AND e.id > $3))
ORDER BY entry_change.changed_at, e.id
LIMIT 501`,
[user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""],
);