Add shared ledger entry sync

This commit is contained in:
openclaw 2026-07-22 00:33:30 +08:00
parent 1cc5c54471
commit d631bb40d8
13 changed files with 577 additions and 21 deletions

View File

@ -2,11 +2,18 @@ import Dexie, { type EntityTable } from "dexie";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import type { LedgerRecord, UserPreference } from "./ledgers";
export type SyncMetadata = {
id: string;
cursor: string;
updatedAt: string;
};
export const db = new Dexie("cents") as Dexie & {
entries: EntityTable<LedgerEntry, "id">;
syncOperations: EntityTable<SyncOperation, "id">;
ledgers: EntityTable<LedgerRecord, "id">;
userPreferences: EntityTable<UserPreference, "id">;
syncMetadata: EntityTable<SyncMetadata, "id">;
};
db.version(1).stores({
@ -20,3 +27,11 @@ db.version(2).stores({
ledgers: "id, updatedAt, archivedAt",
userPreferences: "id, userId, key, updatedAt",
});
db.version(3).stores({
entries: "id, ledgerId, occurredAt, updatedAt, deletedAt",
syncOperations: "id, ledgerId, createdAt, syncedAt",
ledgers: "id, updatedAt, archivedAt",
userPreferences: "id, userId, key, updatedAt",
syncMetadata: "id, updatedAt",
});

View File

@ -26,6 +26,7 @@ export const router = createRouter({
path: "/join-ledger",
name: "join-ledger",
component: () => import("./views/JoinLedgerView.vue"),
meta: { public: true },
},
{
path: "/prototype/entry-detail",

View File

@ -1,22 +1,121 @@
import { defineStore } from "pinia";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import { apiRequest } from "../data/api";
import { db } from "../data/db";
import { createId } from "../data/ids";
import { useAuthStore } from "./auth";
let syncPromise: Promise<void> | null = null;
let syncTriggersStarted = false;
export const useEntryStore = defineStore("entries", {
state: () => ({
entries: [] as LedgerEntry[],
pendingEntryIds: [] as string[],
pendingEntries: [] as Array<{ id: string; ledgerId: string }>,
syncing: false,
syncError: "",
lastSyncedAt: "" as string,
}),
actions: {
async loadEntries() {
await this.refreshLocalEntries();
this.startSyncTriggers();
void this.syncEntries();
},
async refreshLocalEntries() {
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
(entry) => entry.deletedAt === null,
);
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 }]),
).values()];
},
isEntryPending(entryId: string) {
return this.pendingEntryIds.includes(entryId);
},
pendingEntryCount(ledgerId: string) {
return this.pendingEntries.filter((entry) => entry.ledgerId === ledgerId).length;
},
startSyncTriggers() {
if (syncTriggersStarted || typeof window === "undefined") return;
syncTriggersStarted = true;
window.addEventListener("online", () => void this.syncEntries());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void this.syncEntries();
});
window.setInterval(() => void this.syncEntries(), 60_000);
},
async syncEntries() {
const auth = useAuthStore();
if (!auth.user || (typeof navigator !== "undefined" && !navigator.onLine)) {
await this.refreshLocalEntries();
return;
}
const userId = auth.user.id;
if (syncPromise) return syncPromise;
syncPromise = (async () => {
this.syncing = true;
this.syncError = "";
try {
while (true) {
const pending = (await db.syncOperations
.filter((operation) => operation.syncedAt === null)
.toArray())
.filter((operation) => (operation.userId ?? operation.payload.updatedBy) === userId)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
.slice(0, 200);
if (!pending.length) break;
const pushed = await apiRequest<{ acceptedOperationIds: string[] }>("/api/sync/push", {
method: "POST",
body: { operations: pending },
});
if (!pushed.acceptedOperationIds.length) break;
await db.syncOperations.bulkDelete(pushed.acceptedOperationIds);
}
const metadata = await db.syncMetadata.get(userId);
let cursor = metadata?.cursor ?? "";
let hasMore = false;
do {
const path = cursor ? `/api/sync/pull?cursor=${encodeURIComponent(cursor)}` : "/api/sync/pull";
const pulled = await apiRequest<{ entries: LedgerEntry[]; cursor: string; hasMore: boolean }>(path);
const remainingOperations = await db.syncOperations
.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));
await db.transaction("rw", db.entries, db.syncMetadata, async () => {
if (safeCloudEntries.length) await db.entries.bulkPut(safeCloudEntries);
if (pulled.cursor) {
await db.syncMetadata.put({ id: userId, cursor: pulled.cursor, updatedAt: new Date().toISOString() });
}
});
cursor = pulled.cursor;
hasMore = pulled.hasMore;
} while (hasMore);
this.lastSyncedAt = new Date().toISOString();
} catch (error) {
this.syncError = error instanceof Error ? error.message : "同步失败";
} finally {
await this.refreshLocalEntries();
this.syncing = false;
}
})();
try {
await syncPromise;
} finally {
syncPromise = null;
}
},
async addEntry(entry: LedgerEntry) {
const auth = useAuthStore();
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: entry.ledgerId,
entity: "entry",
entityId: entry.id,
@ -31,7 +130,8 @@ export const useEntryStore = defineStore("entries", {
await db.syncOperations.put(operation);
});
await this.loadEntries();
await this.refreshLocalEntries();
void this.syncEntries();
},
async updateEntry(entry: LedgerEntry) {
const auth = useAuthStore();
@ -47,6 +147,7 @@ export const useEntryStore = defineStore("entries", {
};
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: updated.ledgerId,
entity: "entry",
entityId: updated.id,
@ -61,7 +162,8 @@ export const useEntryStore = defineStore("entries", {
await db.syncOperations.put(operation);
});
await this.loadEntries();
await this.refreshLocalEntries();
void this.syncEntries();
return updated;
},
async deleteEntry(entryId: string) {
@ -79,6 +181,7 @@ export const useEntryStore = defineStore("entries", {
};
const operation: SyncOperation = {
id: createId(),
userId: auth.user?.id,
ledgerId: deleted.ledgerId,
entity: "entry",
entityId: deleted.id,
@ -93,7 +196,8 @@ export const useEntryStore = defineStore("entries", {
await db.syncOperations.put(operation);
});
await this.loadEntries();
await this.refreshLocalEntries();
void this.syncEntries();
},
},
});

View File

@ -100,6 +100,12 @@ export const useLedgerStore = defineStore("ledgers", {
memberName(userId: string) {
return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户";
},
async createLedger(input: Pick<LedgerRecord, "name" | "color" | "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" | "defaultCurrency">) {
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
await this.reloadLedgers();

View File

@ -309,6 +309,41 @@ input:focus-visible {
gap: 3px;
}
.sync-notice {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid #efd9c9;
padding: 6px 16px;
background: #fff8f2;
color: #8e481e;
font-size: 12px;
}
.sync-notice > span,
.sync-notice button {
display: inline-flex;
align-items: center;
gap: 5px;
}
.sync-notice button {
flex: 0 0 auto;
min-height: 32px;
border: 0;
border-radius: 7px;
padding: 0 9px;
background: #f0e2d7;
color: #7a3d1b;
font-weight: 680;
}
.sync-notice button:disabled { opacity: .6; }
.sync-notice .spinning { animation: sync-spin .8s linear infinite; }
@keyframes sync-spin { to { transform: rotate(360deg); } }
.list-toolbar-heading {
display: flex;
align-items: baseline;
@ -447,6 +482,13 @@ input:focus-visible {
white-space: nowrap;
}
.entry-copy .entry-sync-pending {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: #b35a24;
}
.entry-copy .entry-category-label::before {
margin-right: 7px;
color: #c4cecb;

View File

@ -8,6 +8,7 @@ import {
ChevronDown,
ChevronRight,
CircleUserRound,
CloudOff,
NotebookText,
Trash2,
WalletCards,
@ -272,7 +273,10 @@ async function deleteEntry() {
</label>
</section>
<p class="entry-created-at">{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}</p>
<p class="entry-created-at">
<span v-if="store.isEntryPending(entry.id)" class="entry-sync-pending" role="img" aria-label="未同步云端" title="未同步云端"><CloudOff :size="13" /></span>
{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}
</p>
<p v-if="errorMessage" class="entry-save-error" role="alert">{{ errorMessage }}</p>
</div>
@ -578,6 +582,14 @@ async function deleteEntry() {
font-size: 12px;
}
.entry-sync-pending {
display: inline-flex;
align-items: center;
margin-right: 4px;
color: #b35a24;
vertical-align: -2px;
}
.entry-save-error {
margin: 8px 16px;
color: #c94635;

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { Check, Clock3, LibraryBig } from "@lucide/vue";
import { Check, Clock3, LibraryBig, LogIn } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiRequest, ApiError } from "../data/api";
import { useAuthStore } from "../stores/auth";
import { useLedgerStore } from "../stores/ledgers";
type LedgerInvitation = {
@ -15,6 +16,7 @@ type LedgerInvitation = {
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const ledgerStore = useLedgerStore();
const invitation = ref<LedgerInvitation | null>(null);
const loading = ref(true);
@ -23,6 +25,11 @@ const errorMessage = ref("");
const key = computed(() => typeof route.query.key === "string" ? route.query.key : "");
onMounted(async () => {
if (!key.value) {
errorMessage.value = "邀请链接不完整";
loading.value = false;
return;
}
try {
const result = await apiRequest<{ invitation: LedgerInvitation }>(`/api/ledger-invitations/${encodeURIComponent(key.value)}`);
invitation.value = result.invitation;
@ -34,6 +41,14 @@ onMounted(async () => {
}
});
async function continueToJoin() {
if (!auth.user) {
await router.push({ name: "login", query: { redirect: route.fullPath } });
return;
}
await accept();
}
async function accept() {
if (!invitation.value || accepting.value) return;
accepting.value = true;
@ -59,7 +74,11 @@ async function accept() {
<small>{{ invitation.inviterName ? `${invitation.inviterName} 邀请你加入` : "你被邀请加入" }}</small>
<h1>{{ invitation.ledgerName }}</h1>
<span class="join-expiry"><Clock3 :size="14" />邀请链接 7 天内有效</span>
<button v-if="invitation.status === 'valid'" type="button" :disabled="accepting" @click="accept"><Check :size="19" />{{ accepting ? "正在加入" : "加入账本" }}</button>
<p v-if="!auth.user && invitation.status === 'valid'" class="join-account-note">账本邀请仅供已有账户使用请先登录成功后会自动返回这里</p>
<button v-if="invitation.status === 'valid'" type="button" :disabled="accepting" @click="continueToJoin">
<Check v-if="auth.user" :size="19" /><LogIn v-else :size="19" />
{{ accepting ? "正在加入" : auth.user ? "加入账本" : "登录后加入" }}
</button>
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
</template>
<div v-else class="join-state error">{{ errorMessage }}</div>
@ -74,7 +93,8 @@ async function accept() {
.join-content small { color:#6f7e7a; font-size:13px; }
.join-content h1 { margin:0; color:#1d302b; font-size:25px; letter-spacing:0; }
.join-expiry { display:flex; align-items:center; gap:5px; color:#71827d; font-size:12px; }
.join-content button { width:100%; height:50px; display:flex; align-items:center; justify-content:center; gap:7px; margin-top:25px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.join-account-note { max-width:310px; margin:18px 0 0!important; color:#667772!important; line-height:1.6; }
.join-content button { width:100%; height:50px; display:flex; align-items:center; justify-content:center; gap:7px; margin-top:12px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.join-content button:disabled { opacity:.5; }
.join-content p,.join-state.error { color:#c74d39; font-size:12px; }
.join-state { color:#6e7f7a; font-size:14px; }

View File

@ -4,9 +4,11 @@ import {
BookOpen,
ChevronDown,
CircleUserRound,
CloudOff,
Copy,
LoaderCircle,
MoreHorizontal,
RefreshCw,
Search,
Settings,
Share2,
@ -58,6 +60,7 @@ const totals = computed(() => {
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 dailyTotals = computed(() => {
const summaries = new Map<string, { income: number; expense: number }>();
@ -279,6 +282,12 @@ async function shareLedger() {
</header>
<section ref="ledgerContent" class="ledger-content" aria-label="账本流水">
<div v-if="pendingEntryCount" class="sync-notice" role="status">
<span><CloudOff :size="16" />{{ pendingEntryCount }} 条账目未同步云端</span>
<button type="button" :disabled="store.syncing" @click="store.syncEntries">
<RefreshCw :size="15" :class="{ spinning: store.syncing }" />{{ store.syncing ? "同步中" : "立即同步" }}
</button>
</div>
<div class="list-toolbar">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
@ -318,6 +327,7 @@ async function shareLedger() {
<div class="entry-subline">
<span class="entry-recorder"><CircleUserRound :size="12" />{{ ledgerStore.memberName(entry.createdBy) }}</span>
<span class="entry-category-label">{{ categoryLabel(entry) }}</span>
<span v-if="store.isEntryPending(entry.id)" class="entry-sync-pending" role="img" aria-label="未同步云端" title="未同步云端"><CloudOff :size="13" /></span>
</div>
</div>
<div class="entry-value">

View File

@ -1,14 +1,23 @@
<script setup lang="ts">
import { Check, ChevronRight, LibraryBig } from "@lucide/vue";
import { computed, onMounted } from "vue";
import type { CurrencyCode } from "@cents/domain";
import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { ApiError } from "../data/api";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
const colors = ["#087f72", "#3478e5", "#7559d9", "#f05d3f", "#d84486", "#15956d"];
const router = useRouter();
const entryStore = useEntryStore();
const ledgerStore = useLedgerStore();
const createOpen = ref(false);
const name = ref("");
const color = ref(colors[0]);
const currency = ref<CurrencyCode>("CNY");
const saving = ref(false);
const errorMessage = ref("");
const entryCounts = computed(() => {
const counts = new Map<string, number>();
@ -24,11 +33,43 @@ async function selectLedger(ledgerId: string) {
await ledgerStore.setCurrentLedger(ledgerId);
await router.push("/");
}
function openCreate() {
name.value = "";
color.value = colors[ledgerStore.ledgers.length % colors.length];
currency.value = ledgerStore.currentLedger?.defaultCurrency ?? "CNY";
errorMessage.value = "";
createOpen.value = true;
}
function closeCreate() {
if (!saving.value) createOpen.value = false;
}
async function createLedger() {
const trimmedName = name.value.trim();
if (!trimmedName || saving.value) return;
saving.value = true;
errorMessage.value = "";
try {
await ledgerStore.createLedger({ name: trimmedName, color: color.value, defaultCurrency: currency.value });
createOpen.value = false;
await router.push("/");
} catch (error) {
errorMessage.value = error instanceof ApiError ? error.message : "创建账本失败,请稍后重试";
} finally {
saving.value = false;
}
}
</script>
<template>
<main class="app-shell ledgers-shell">
<header class="section-appbar"><strong>账本</strong><span>{{ ledgerStore.ledgers.length }} </span></header>
<header class="section-appbar">
<strong>账本</strong>
<span>{{ ledgerStore.ledgers.length }} </span>
<button type="button" aria-label="新增账本" title="新增账本" @click="openCreate"><Plus :size="22" /></button>
</header>
<div class="ledgers-scroll">
<p class="ledgers-caption">当前账本决定流水统计和快速记账的默认归属</p>
<section class="ledger-list" aria-label="全部账本">
@ -41,14 +82,36 @@ async function selectLedger(ledgerId: string) {
</section>
</div>
<QuickEntryHost />
<Transition name="create-sheet">
<div v-if="createOpen" class="create-ledger-layer" @keydown.esc="closeCreate">
<button class="create-ledger-scrim" type="button" aria-label="关闭新增账本" @click="closeCreate"></button>
<form class="create-ledger-sheet" role="dialog" aria-modal="true" aria-labelledby="create-ledger-title" @submit.prevent="createLedger">
<div class="create-sheet-handle"></div>
<header>
<div><strong id="create-ledger-title">新增账本</strong><span>创建后你将成为账本拥有者</span></div>
<button type="button" aria-label="关闭" title="关闭" @click="closeCreate"><X :size="20" /></button>
</header>
<label class="create-ledger-name"><span>账本名称</span><input v-model="name" maxlength="40" placeholder="例如:旅行计划" autocomplete="off" autofocus required /></label>
<label class="create-ledger-currency"><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="HKD">港币 HKD</option></select></label>
<fieldset class="create-ledger-colors">
<legend>账本颜色</legend>
<div><button v-for="item in colors" :key="item" type="button" :class="{ active: color === item }" :style="{ background: item }" :aria-label="`选择颜色 ${item}`" @click="color = item"><Check v-if="color === item" :size="17" /></button></div>
</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>
</div>
</Transition>
</main>
</template>
<style scoped>
.ledgers-shell { background:#f5f8f7; }
.section-appbar { height:70px; display:flex; align-items:end; justify-content:space-between; border-bottom:1px solid #dce7e4; padding:14px 18px 12px; background:#fff; }
.section-appbar { height:70px; display:grid; grid-template-columns:1fr auto 42px; align-items:end; gap:10px; border-bottom:1px solid #dce7e4; padding:14px 12px 8px 18px; background:#fff; }
.section-appbar strong { font-size:20px; }
.section-appbar span { color:#7b8884; font-size:12px; }
.section-appbar > span { align-self:center; color:#7b8884; font-size:12px; }
.section-appbar > button { width:42px; height:42px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#087f72; }
.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; }
@ -59,5 +122,28 @@ async function selectLedger(ledgerId: string) {
.ledger-list strong { overflow:hidden; font-size:15px; text-overflow:ellipsis; white-space:nowrap; }
.ledger-list small { color:#7b8884; font-size:11px; }
.current-ledger-check { color:#087f72; }
.create-ledger-layer { position:absolute; inset:0; z-index:20; }
.create-ledger-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.42); backdrop-filter:blur(3px); }
.create-ledger-sheet { position:absolute; right:0; bottom:0; left:0; max-height:calc(100% - 48px); overflow-y:auto; border-radius:14px 14px 0 0; padding:0 16px calc(18px + env(safe-area-inset-bottom)); background:#fff; color:#26342f; box-shadow:0 -16px 36px rgba(22,45,40,.22); }
.create-sheet-handle { width:38px; height:4px; margin:8px auto 3px; border-radius:2px; background:#b9cac5; }
.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 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:flex; gap:12px; margin-top:10px; }
.create-ledger-colors button { width:38px; height:38px; display:grid; place-items:center; border:3px solid #fff; border-radius:50%; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
.create-ledger-colors button.active { box-shadow:0 0 0 2px #263b36; }
.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; }
.create-sheet-enter-active,.create-sheet-leave-active { transition:opacity .2s ease; }
.create-sheet-enter-active .create-ledger-sheet,.create-sheet-leave-active .create-ledger-sheet { transition:transform .22s ease; }
.create-sheet-enter-from,.create-sheet-leave-to { opacity:0; }
.create-sheet-enter-from .create-ledger-sheet,.create-sheet-leave-to .create-ledger-sheet { transform:translateY(100%); }
@media (max-width:360px) { .ledgers-scroll { padding-right:12px; padding-left:12px; } }
</style>

View File

@ -18,6 +18,7 @@ const errorMessage = ref("");
const redirect = computed(() => typeof route.query.redirect === "string" && route.query.redirect.startsWith("/")
? route.query.redirect
: "/");
const joiningLedger = computed(() => redirect.value.startsWith("/join-ledger?"));
async function login() {
if (!name.value.trim() || !password.value || submitting.value) return;
@ -39,14 +40,18 @@ async function login() {
<main class="app-shell login-shell">
<header><span><KeyRound :size="18" /></span><strong>Cents</strong></header>
<section class="login-content">
<div class="login-heading"><h1>登录</h1><p>继续使用家庭账本</p></div>
<div class="login-heading">
<h1>登录</h1>
<p>{{ joiningLedger ? "登录已有账户后加入受邀账本" : "继续使用家庭账本" }}</p>
</div>
<form @submit.prevent="login">
<label><span>姓名</span><div><UserRound :size="19" /><input v-model="name" maxlength="40" autocomplete="username" /></div></label>
<label><span>密码</span><div><KeyRound :size="19" /><input v-model="password" :type="passwordVisible ? 'text' : 'password'" maxlength="128" autocomplete="current-password" /><button type="button" :aria-label="passwordVisible ? '隐藏密码' : '显示密码'" :title="passwordVisible ? '隐藏密码' : '显示密码'" @click="passwordVisible = !passwordVisible"><EyeOff v-if="passwordVisible" :size="19" /><Eye v-else :size="19" /></button></div></label>
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
<button class="login-submit" type="submit" :disabled="submitting || !name.trim() || !password"><LogIn :size="19" />{{ submitting ? "正在登录" : "登录" }}</button>
</form>
<small>没有账号时需要先打开应用邀请链接</small>
<small v-if="joiningLedger">账本邀请不能创建账户没有账户时请先向管理员获取应用邀请</small>
<small v-else>没有账号时需要先打开应用邀请链接</small>
</section>
</main>
</template>

View File

@ -29,6 +29,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
export type SyncOperation = {
id: string;
userId?: string;
ledgerId: string;
entity: "entry";
entityId: string;
@ -37,4 +38,3 @@ export type SyncOperation = {
createdAt: string;
syncedAt: string | null;
};

View File

@ -81,6 +81,37 @@ CREATE TABLE IF NOT EXISTS sessions (
);
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,
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
amount integer NOT NULL CHECK (amount > 0),
currency varchar(3) NOT NULL,
base_currency varchar(3) NOT NULL,
base_amount integer NOT NULL CHECK (base_amount > 0),
exchange_rate varchar(32) NOT NULL,
exchange_rate_source varchar(8) NOT NULL CHECK (exchange_rate_source IN ('manual', 'system')),
category_id varchar(100) NOT NULL,
note varchar(500) NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
created_by text REFERENCES users(id) ON DELETE SET NULL,
updated_by text REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
deleted_at timestamptz,
version integer NOT NULL CHECK (version > 0),
server_updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS entries_ledger_occurred
ON entries (ledger_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS entry_sync_operations (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
processed_at timestamptz NOT NULL DEFAULT now()
);
`;
export async function initializeDatabase() {

View File

@ -1,6 +1,7 @@
import cookie from "@fastify/cookie";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { initializeDatabase, pool } from "./db.js";
import {
@ -46,6 +47,7 @@ server.addHook("onSend", async (request, reply, payload) => {
request.url.startsWith("/api/auth/")
|| request.url.startsWith("/api/invitations/")
|| request.url.startsWith("/api/ledger-invitations/")
|| request.url.startsWith("/api/sync/")
|| request.url === "/api/me"
) {
reply.header("Cache-Control", "no-store");
@ -112,6 +114,59 @@ function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Da
return "valid";
}
const currencies = new Set(["CNY", "USD", "EUR", "JPY", "HKD"]);
function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
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
&& (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)
&& typeof entry.baseCurrency === "string" && currencies.has(entry.baseCurrency)
&& Number.isSafeInteger(entry.baseAmount) && entry.baseAmount! > 0 && entry.baseAmount! <= 2_147_483_647
&& typeof entry.exchangeRate === "string" && entry.exchangeRate.length > 0 && entry.exchangeRate.length <= 32
&& (entry.exchangeRateSource === "manual" || entry.exchangeRateSource === "system")
&& typeof entry.categoryId === "string" && entry.categoryId.length > 0 && entry.categoryId.length <= 100
&& typeof entry.note === "string" && entry.note.length <= 500
&& validDate(entry.occurredAt) && validDate(entry.createdAt) && validDate(entry.updatedAt)
&& (entry.deletedAt === null || validDate(entry.deletedAt))
&& Number.isSafeInteger(entry.version) && entry.version! > 0;
}
function validSyncOperation(value: unknown): value is SyncOperation {
if (!value || typeof value !== "object") return false;
const operation = value as Partial<SyncOperation>;
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);
}
type SyncCursor = { serverUpdatedAt: string; id: string };
function encodeSyncCursor(cursor: SyncCursor) {
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}
function decodeSyncCursor(value: string): SyncCursor | null {
try {
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Partial<SyncCursor>;
if (!validDate(cursor.serverUpdatedAt) || typeof cursor.id !== "string" || cursor.id.length > 100) return null;
return { serverUpdatedAt: cursor.serverUpdatedAt!, id: cursor.id };
} catch {
return null;
}
}
server.get("/health", async () => ({ ok: true, service: "cents-api" }));
server.get("/api/auth/session", async (request, reply) => ({ user: await currentUser(request, reply) }));
@ -176,6 +231,46 @@ server.get("/api/ledgers", async (request, reply) => {
return { ledgers: result.rows };
});
server.post<{ Body: { name?: string; color?: string; defaultCurrency?: string } }>(
"/api/ledgers",
async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
const name = request.body.name?.trim() ?? "";
const color = request.body.color ?? "#087f72";
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: "账本颜色无效" });
if (!["CNY", "USD", "EUR", "JPY", "HKD"].includes(defaultCurrency)) {
return reply.code(400).send({ error: "默认币种无效" });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const ledgerId = createId();
const result = await client.query(
`INSERT INTO ledgers (id, name, color, default_currency)
VALUES ($1, $2, $3, $4)
RETURNING id, name, color, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[ledgerId, name, color, defaultCurrency],
);
await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
[ledgerId, user.id],
);
await client.query("COMMIT");
return reply.code(201).send({ ledger: { ...result.rows[0], role: "owner" } });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
},
);
server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
@ -344,6 +439,10 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
ON CONFLICT (ledger_id, user_id) DO UPDATE SET removed_at = NULL, joined_at = now()`,
[invitation!.ledgerId, user.id, invitation!.role],
);
await client.query(
"UPDATE entries SET server_updated_at = now() WHERE ledger_id = $1",
[invitation!.ledgerId],
);
await client.query(
"UPDATE ledger_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
[user.id, invitation!.id],
@ -358,14 +457,139 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
}
});
server.post("/api/sync/push", async (request, reply) => {
if (!await requireUser(request, reply)) return;
return { accepted: true, operations: [] };
server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
const operations = request.body.operations ?? [];
if (!Array.isArray(operations) || operations.length > 200 || !operations.every(validSyncOperation)) {
return reply.code(400).send({ error: "同步数据无效" });
}
const client = await pool.connect();
const acceptedOperationIds: string[] = [];
try {
await client.query("BEGIN");
for (const operation of operations) {
const alreadyProcessed = await client.query(
"SELECT 1 FROM entry_sync_operations WHERE id = $1",
[operation.id],
);
if (alreadyProcessed.rowCount) {
acceptedOperationIds.push(operation.id);
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],
);
if (!targetRole.rowCount) {
await client.query("ROLLBACK");
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],
);
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: "无权移动该流水" });
}
}
const entry = operation.payload;
await client.query(
`INSERT INTO entries (
id, ledger_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 (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$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,
base_currency = EXCLUDED.base_currency,
base_amount = EXCLUDED.base_amount,
exchange_rate = EXCLUDED.exchange_rate,
exchange_rate_source = EXCLUDED.exchange_rate_source,
category_id = EXCLUDED.category_id,
note = EXCLUDED.note,
occurred_at = EXCLUDED.occurred_at,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at,
deleted_at = EXCLUDED.deleted_at,
version = EXCLUDED.version,
server_updated_at = now()
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.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version,
],
);
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)",
[operation.id, user.id],
);
acceptedOperationIds.push(operation.id);
}
await client.query("COMMIT");
return { acceptedOperationIds };
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
});
server.get("/api/sync/pull", async (request, reply) => {
if (!await requireUser(request, reply)) return;
return { cursor: null, operations: [] };
server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
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,
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
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
LIMIT 501`,
[user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""],
);
const pageRows = result.rows.slice(0, 500);
const lastRow = pageRows.at(-1);
const entries = pageRows.map(({ serverUpdatedAt: _serverUpdatedAt, ...entry }) => entry);
return {
entries,
cursor: lastRow
? encodeSyncCursor({ serverUpdatedAt: lastRow.serverUpdatedAt.toISOString(), id: lastRow.id })
: request.query.cursor ?? "",
hasMore: result.rows.length > 500,
};
});
const port = Number(process.env.PORT ?? 3000);