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"> <script setup lang="ts">
import type { EntryType } from "@cents/domain"; 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 { computed, ref } from "vue";
import { import {
categories, categories,
@ -15,20 +15,20 @@ type SavedEntry = {
categoryId: string; categoryId: string;
note: string; note: string;
occurredAt: string; occurredAt: string;
ledgerIds: string[];
}; };
type SelectorLevel = "type" | "category" | "subcategory"; type SelectorLevel = "type" | "category" | "subcategory";
defineProps<{ const props = defineProps<{
ledgerId: string; ledgerId: string;
ledgerName: string; ledgerName: string;
ledgerOptions: Array<{ id: string; name: string }>; ledgerOptions: Array<{ id: string; name: string; color: string }>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
save: [entry: SavedEntry]; save: [entry: SavedEntry];
changeLedger: [ledgerId: string];
}>(); }>();
const expression = ref("0"); const expression = ref("0");
@ -40,6 +40,8 @@ const dateInput = ref<HTMLInputElement | null>(null);
const selectorOpen = ref(false); const selectorOpen = ref(false);
const selectorLevel = ref<SelectorLevel>("type"); const selectorLevel = ref<SelectorLevel>("type");
const noteEditing = ref(false); const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const selectedLedgerIds = ref([props.ledgerId]);
const axisOffset = ref(0); const axisOffset = ref(0);
let axisPointerId: number | null = null; let axisPointerId: number | null = null;
let axisDragStartY = 0; let axisDragStartY = 0;
@ -65,6 +67,9 @@ const displayAmount = computed(() =>
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(calculatedAmount.value), }).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 dateLabel = computed(() => {
const date = new Date(occurredAt.value); const date = new Date(occurredAt.value);
const now = new Date(); const now = new Date();
@ -315,6 +320,22 @@ function startNoteEditing() {
noteEditing.value = true; 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() { function saveEntry() {
const amount = Math.round(calculatedAmount.value * 100); const amount = Math.round(calculatedAmount.value * 100);
if (!Number.isFinite(amount) || amount <= 0) return; if (!Number.isFinite(amount) || amount <= 0) return;
@ -325,6 +346,7 @@ function saveEntry() {
categoryId: selectedCategory.value?.id ?? entryType.value, categoryId: selectedCategory.value?.id ?? entryType.value,
note: note.value.trim(), note: note.value.trim(),
occurredAt: new Date(occurredAt.value).toISOString(), occurredAt: new Date(occurredAt.value).toISOString(),
ledgerIds: selectedLedgerIds.value,
}); });
} }
</script> </script>
@ -342,18 +364,11 @@ function saveEntry() {
<strong class="amount-value">{{ displayAmount }}</strong> <strong class="amount-value">{{ displayAmount }}</strong>
</div> </div>
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span> <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" /> <BookOpen :size="13" />
<span>记入 {{ ledgerName }}</span> <span>记入 {{ selectedLedgerLabel }}</span>
<ChevronDown :size="13" /> <ChevronDown :size="13" />
<select </button>
: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>
</div> </div>
<button class="header-date-button" type="button" @click="openDatePicker"> <button class="header-date-button" type="button" @click="openDatePicker">
<CalendarDays :size="16" /> <CalendarDays :size="16" />
@ -503,4 +518,20 @@ function saveEntry() {
</button> </button>
</div> </div>
</section> </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> </template>

View File

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

View File

@ -35,3 +35,31 @@ db.version(3).stores({
userPreferences: "id, userId, key, updatedAt", userPreferences: "id, userId, key, updatedAt",
syncMetadata: "id, 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"; import { createId } from "./ids";
type EntryInput = { type EntryInput = {
ledgerId: string; ledgerIds: string[];
type: EntryType; type: EntryType;
amount: number; amount: number;
currency: CurrencyCode; currency: CurrencyCode;
@ -19,7 +19,8 @@ export function createEntry(input: EntryInput): LedgerEntry {
return { return {
id: createId(), id: createId(),
ledgerId: input.ledgerId, ownerId: input.createdBy ?? "local-user",
ledgerIds: input.ledgerIds,
type: input.type, type: input.type,
amount: input.amount, amount: input.amount,
currency: input.currency, currency: input.currency,
@ -49,10 +50,10 @@ export function createDemoEntries(): LedgerEntry[] {
}; };
return [ return [
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }), createEntry({ ledgerIds: ["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({ ledgerIds: ["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({ ledgerIds: ["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({ ledgerIds: ["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: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
]; ];
} }

View File

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

View File

@ -40,11 +40,20 @@ export const useLedgerStore = defineStore("ledgers", {
this.ledgers = serverLedgers; this.ledgers = serverLedgers;
const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id)); const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id));
const inaccessibleEntries = await db.entries.filter((entry) => !allowedIds.has(entry.ledgerId)).primaryKeys(); const localEntries = await db.entries.toArray();
const inaccessibleOperations = await db.syncOperations.filter((item) => !allowedIds.has(item.ledgerId)).primaryKeys(); 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.transaction("rw", db.ledgers, db.entries, db.syncOperations, async () => {
await db.ledgers.clear(); await db.ledgers.clear();
if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers); 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 (inaccessibleEntries.length) await db.entries.bulkDelete(inaccessibleEntries);
if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations); if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations);
}); });

View File

@ -672,6 +672,7 @@ input:focus-visible {
color: #087f72; color: #087f72;
font-size: 11px; font-size: 11px;
font-weight: 680; font-weight: 680;
border: 0;
} }
.quick-ledger-target span { .quick-ledger-target span {
@ -680,13 +681,22 @@ input:focus-visible {
white-space: nowrap; white-space: nowrap;
} }
.quick-ledger-target select { .quick-ledger-picker-layer { position:absolute; inset:0; z-index:30; }
position: absolute; .quick-ledger-picker-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.42); backdrop-filter:blur(2px); }
inset: 0; .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; }
width: 100%; .quick-ledger-picker-handle { width:38px; height:4px; margin:8px auto 3px; border-radius:2px; background:#b9cac5; }
opacity: 0; .quick-ledger-picker > header { min-height:56px; display:flex; align-items:center; justify-content:space-between; }
cursor: pointer; .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 { .amount-result {
min-width: 0; min-width: 0;

View File

@ -5,7 +5,6 @@ import {
BookOpen, BookOpen,
CalendarDays, CalendarDays,
Check, Check,
ChevronDown,
ChevronRight, ChevronRight,
CircleUserRound, CircleUserRound,
CloudOff, CloudOff,
@ -38,10 +37,11 @@ const saving = ref(false);
const savedToast = ref(false); const savedToast = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const categorySheetMode = ref<CategorySheetMode | null>(null); const categorySheetMode = ref<CategorySheetMode | null>(null);
const ledgerPickerOpen = ref(false);
const dateInput = ref<HTMLInputElement | null>(null); const dateInput = ref<HTMLInputElement | null>(null);
const amount = ref(""); const amount = ref("");
const ledgerId = ref("local-ledger"); const ledgerIds = ref<string[]>([]);
const parentCategoryId = ref(""); const parentCategoryId = ref("");
const childCategoryId = ref(""); const childCategoryId = ref("");
const note = ref(""); const note = ref("");
@ -78,7 +78,9 @@ const timeInvalid = computed(() => {
return Number.isNaN(new Date(occurredAt.value).getTime()); return Number.isNaN(new Date(occurredAt.value).getTime());
}); });
const ledgerLabel = computed(() => 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(() => { const createdAtLabel = computed(() => {
if (!entry.value) return ""; if (!entry.value) return "";
@ -102,7 +104,7 @@ onMounted(async () => {
function hydrate(value: LedgerEntry) { function hydrate(value: LedgerEntry) {
entry.value = value; entry.value = value;
amount.value = (value.amount / 100).toFixed(2); amount.value = (value.amount / 100).toFixed(2);
ledgerId.value = value.ledgerId; ledgerIds.value = [...value.ledgerIds];
note.value = value.note; note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt)); occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
const path = findCategoryPath(value.type, value.categoryId); const path = findCategoryPath(value.type, value.categoryId);
@ -134,6 +136,14 @@ function chooseChild(category: Category) {
categorySheetMode.value = null; 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() { async function saveEntry() {
if (!entry.value || saving.value) return; if (!entry.value || saving.value) return;
const numericAmount = Number(amount.value.replaceAll(",", "")); const numericAmount = Number(amount.value.replaceAll(",", ""));
@ -159,7 +169,7 @@ async function saveEntry() {
...entry.value, ...entry.value,
amount: cents, amount: cents,
baseAmount: Math.round(cents * exchangeRate), baseAmount: Math.round(cents * exchangeRate),
ledgerId: ledgerId.value, ledgerIds: ledgerIds.value,
categoryId: childCategoryId.value || parentCategoryId.value, categoryId: childCategoryId.value || parentCategoryId.value,
note: note.value.trim(), note: note.value.trim(),
occurredAt: occurredDate.toISOString(), occurredAt: occurredDate.toISOString(),
@ -250,18 +260,15 @@ async function deleteEntry() {
tabindex="-1" 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-icon ledger"><BookOpen :size="19" /></span>
<span class="entry-field-copy"><small>账本</small><strong>{{ ledgerLabel }}</strong></span> <span class="entry-field-copy"><small>账本</small><strong>{{ ledgerLabel }}</strong></span>
<select v-model="ledgerId" aria-label="账本"> <ChevronRight :size="18" />
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.label }}</option> </button>
</select>
<ChevronDown :size="18" />
</label>
<div class="entry-edit-row readonly-row"> <div class="entry-edit-row readonly-row">
<span class="entry-field-icon member"><CircleUserRound :size="19" /></span> <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> </div>
<label class="entry-note-row"> <label class="entry-note-row">
@ -333,6 +340,22 @@ async function deleteEntry() {
</div> </div>
</Transition> </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"> <Transition name="toast">
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div> <div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
</Transition> </Transition>

View File

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

View File

@ -21,7 +21,9 @@ const errorMessage = ref("");
const entryCounts = computed(() => { const entryCounts = computed(() => {
const counts = new Map<string, number>(); 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; return counts;
}); });

View File

@ -5,15 +5,19 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue"; import QuickEntryHost from "../components/QuickEntryHost.vue";
import { findCategory } from "../data/categories"; import { findCategory } from "../data/categories";
import { useAuthStore } from "../stores/auth";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; import { useLedgerStore } from "../stores/ledgers";
type StatsTab = "flow" | "category" | "member"; type StatsTab = "flow" | "category" | "member";
type StatsScope = "ledger" | "personal";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore();
const entryStore = useEntryStore(); const entryStore = useEntryStore();
const ledgerStore = useLedgerStore(); const ledgerStore = useLedgerStore();
const activeTab = ref<StatsTab>("flow"); const activeTab = ref<StatsTab>("flow");
const scope = ref<StatsScope>("ledger");
const monthLabel = computed(() => { const monthLabel = computed(() => {
const date = new Date(); const date = new Date();
@ -24,7 +28,10 @@ const entries = computed(() => {
const current = new Date(); const current = new Date();
return entryStore.entries.filter((entry) => { return entryStore.entries.filter((entry) => {
const occurredAt = new Date(entry.occurredAt); 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.getFullYear() === current.getFullYear()
&& occurredAt.getMonth() === current.getMonth(); && occurredAt.getMonth() === current.getMonth();
}); });
@ -107,6 +114,11 @@ function money(value: number) {
<div class="stats-scroll"> <div class="stats-scroll">
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button> <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="本月统计"> <section class="stats-summary" aria-label="本月统计">
<div> <div>
<span>结余</span> <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-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; } .stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; } .stats-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 { 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 { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
.stats-summary > div:first-child { border-left: 0; } .stats-summary > div:first-child { border-left: 0; }

View File

@ -6,7 +6,8 @@ export type ExchangeRateSource = "manual" | "system";
export type LedgerEntry = { export type LedgerEntry = {
id: string; id: string;
ledgerId: string; ownerId: string;
ledgerIds: string[];
type: EntryType; type: EntryType;
amount: number; amount: number;
currency: CurrencyCode; currency: CurrencyCode;
@ -30,7 +31,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
export type SyncOperation = { export type SyncOperation = {
id: string; id: string;
userId?: string; userId?: string;
ledgerId: string; ledgerIds: string[];
entity: "entry"; entity: "entry";
entityId: string; entityId: string;
action: SyncOperationAction; 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 ( CREATE TABLE IF NOT EXISTS entries (
id text PRIMARY KEY, 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')), type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
amount integer NOT NULL CHECK (amount > 0), amount integer NOT NULL CHECK (amount > 0),
currency varchar(3) NOT NULL, currency varchar(3) NOT NULL,
@ -104,8 +104,41 @@ CREATE TABLE IF NOT EXISTS entries (
server_updated_at timestamptz NOT NULL DEFAULT now() server_updated_at timestamptz NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS entries_ledger_occurred ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
ON entries (ledger_id, occurred_at DESC); 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 ( CREATE TABLE IF NOT EXISTS entry_sync_operations (
id text PRIMARY KEY, id text PRIMARY KEY,

View File

@ -120,11 +120,18 @@ function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value)); 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 { function validEntry(value: unknown): value is LedgerEntry {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const entry = value as Partial<LedgerEntry>; const entry = value as Partial<LedgerEntry>;
return typeof entry.id === "string" && entry.id.length > 0 && entry.id.length <= 100 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") && (entry.type === "expense" || entry.type === "income")
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647 && Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
&& typeof entry.currency === "string" && currencies.has(entry.currency) && 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 { function validSyncOperation(value: unknown): value is SyncOperation {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const operation = value as Partial<SyncOperation>; const operation = value as Partial<SyncOperation>;
const payload = operation.payload;
return typeof operation.id === "string" && operation.id.length > 0 && operation.id.length <= 100 return typeof operation.id === "string" && operation.id.length > 0 && operation.id.length <= 100
&& operation.entity === "entry" && operation.entity === "entry"
&& (operation.action === "create" || operation.action === "update" || operation.action === "delete") && (operation.action === "create" || operation.action === "update" || operation.action === "delete")
&& validEntry(operation.payload) && validEntry(payload)
&& operation.entityId === operation.payload.id && operation.entityId === payload.id
&& operation.ledgerId === operation.payload.ledgerId && validLedgerIds(operation.ledgerIds)
&& (operation.action !== "delete" || operation.payload.deletedAt !== null); && 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 }; 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], [invitation!.ledgerId, user.id, invitation!.role],
); );
await client.query( 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], [invitation!.ledgerId],
); );
await client.query( await client.query(
@ -479,35 +493,43 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
continue; continue;
} }
const targetRole = await client.query( const existing = await client.query<{ ownerId: string; canAccess: boolean }>(
`SELECT role FROM ledger_members `SELECT e.owner_id AS "ownerId",
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`, (e.owner_id = $2 OR EXISTS (
[operation.ledgerId, user.id], 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"); await client.query("ROLLBACK");
return reply.code(403).send({ error: "无权同步该账本的流水" }); return reply.code(403).send({ error: "无权修改该流水" });
} }
const existing = await client.query<{ ledgerId: string }>( const inaccessibleTargets = await client.query<{ count: number }>(
`SELECT ledger_id AS "ledgerId" FROM entries WHERE id = $1`, `SELECT count(*)::int AS count
[operation.entityId], 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) { if (inaccessibleTargets.rows[0]?.count) {
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"); await client.query("ROLLBACK");
return reply.code(403).send({ error: "无权移动该流水" }); return reply.code(403).send({ error: "无权关联其中一个账本" });
}
} }
const entry = operation.payload; const entry = operation.payload;
await client.query( const writeResult = await client.query(
`INSERT INTO entries ( `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, exchange_rate, exchange_rate_source, category_id, note, occurred_at,
created_by, updated_by, created_at, updated_at, deleted_at, version created_by, updated_by, created_at, updated_at, deleted_at, version
) VALUES ( ) VALUES (
@ -515,7 +537,6 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
$13, $14, $15, $16, $17, $18 $13, $14, $15, $16, $17, $18
) )
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
ledger_id = EXCLUDED.ledger_id,
type = EXCLUDED.type, type = EXCLUDED.type,
amount = EXCLUDED.amount, amount = EXCLUDED.amount,
currency = EXCLUDED.currency, currency = EXCLUDED.currency,
@ -534,12 +555,38 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
WHERE entries.version < EXCLUDED.version WHERE entries.version < EXCLUDED.version
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`, 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.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt, entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version, 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("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
await client.query( await client.query(
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)", "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; const cursor = request.query.cursor ? decodeSyncCursor(request.query.cursor) : null;
if (request.query.cursor && !cursor) return reply.code(400).send({ error: "同步游标无效" }); if (request.query.cursor && !cursor) return reply.code(400).send({ error: "同步游标无效" });
const result = await pool.query<LedgerEntry & { serverUpdatedAt: Date }>( 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.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource", e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource",
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt", e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
e.created_by AS "createdBy", e.updated_by AS "updatedBy", e.created_by AS "createdBy", e.updated_by AS "updatedBy",
e.created_at AS "createdAt", e.updated_at AS "updatedAt", e.created_at AS "createdAt", e.updated_at AS "updatedAt",
e.deleted_at AS "deletedAt", e.version, e.deleted_at AS "deletedAt", e.version,
e.server_updated_at AS "serverUpdatedAt" entry_change.changed_at AS "serverUpdatedAt"
FROM entries e JOIN ledger_members m ON m.ledger_id = e.ledger_id FROM entries e
WHERE m.user_id = $1 AND m.removed_at IS NULL 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 AND ($2::timestamptz IS NULL
OR e.server_updated_at > $2::timestamptz OR entry_change.changed_at > $2::timestamptz
OR (e.server_updated_at = $2::timestamptz AND e.id > $3)) OR (entry_change.changed_at = $2::timestamptz AND e.id > $3))
ORDER BY e.server_updated_at, e.id ORDER BY entry_change.changed_at, e.id
LIMIT 501`, LIMIT 501`,
[user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""], [user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""],
); );