Add shared ledger entry sync
This commit is contained in:
parent
1cc5c54471
commit
d631bb40d8
|
|
@ -2,11 +2,18 @@ import Dexie, { type EntityTable } from "dexie";
|
||||||
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||||
import type { LedgerRecord, UserPreference } from "./ledgers";
|
import type { LedgerRecord, UserPreference } from "./ledgers";
|
||||||
|
|
||||||
|
export type SyncMetadata = {
|
||||||
|
id: string;
|
||||||
|
cursor: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const db = new Dexie("cents") as Dexie & {
|
export const db = new Dexie("cents") as Dexie & {
|
||||||
entries: EntityTable<LedgerEntry, "id">;
|
entries: EntityTable<LedgerEntry, "id">;
|
||||||
syncOperations: EntityTable<SyncOperation, "id">;
|
syncOperations: EntityTable<SyncOperation, "id">;
|
||||||
ledgers: EntityTable<LedgerRecord, "id">;
|
ledgers: EntityTable<LedgerRecord, "id">;
|
||||||
userPreferences: EntityTable<UserPreference, "id">;
|
userPreferences: EntityTable<UserPreference, "id">;
|
||||||
|
syncMetadata: EntityTable<SyncMetadata, "id">;
|
||||||
};
|
};
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
|
|
@ -20,3 +27,11 @@ db.version(2).stores({
|
||||||
ledgers: "id, updatedAt, archivedAt",
|
ledgers: "id, updatedAt, archivedAt",
|
||||||
userPreferences: "id, userId, key, updatedAt",
|
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",
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export const router = createRouter({
|
||||||
path: "/join-ledger",
|
path: "/join-ledger",
|
||||||
name: "join-ledger",
|
name: "join-ledger",
|
||||||
component: () => import("./views/JoinLedgerView.vue"),
|
component: () => import("./views/JoinLedgerView.vue"),
|
||||||
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/prototype/entry-detail",
|
path: "/prototype/entry-detail",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,121 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||||
|
import { apiRequest } from "../data/api";
|
||||||
import { db } from "../data/db";
|
import { db } from "../data/db";
|
||||||
import { createId } from "../data/ids";
|
import { createId } from "../data/ids";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
|
let syncPromise: Promise<void> | null = null;
|
||||||
|
let syncTriggersStarted = false;
|
||||||
|
|
||||||
export const useEntryStore = defineStore("entries", {
|
export const useEntryStore = defineStore("entries", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
entries: [] as LedgerEntry[],
|
entries: [] as LedgerEntry[],
|
||||||
|
pendingEntryIds: [] as string[],
|
||||||
|
pendingEntries: [] as Array<{ id: string; ledgerId: string }>,
|
||||||
|
syncing: false,
|
||||||
|
syncError: "",
|
||||||
|
lastSyncedAt: "" as string,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadEntries() {
|
async loadEntries() {
|
||||||
|
await this.refreshLocalEntries();
|
||||||
|
this.startSyncTriggers();
|
||||||
|
void this.syncEntries();
|
||||||
|
},
|
||||||
|
async refreshLocalEntries() {
|
||||||
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
|
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
|
||||||
(entry) => entry.deletedAt === null,
|
(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) {
|
async addEntry(entry: LedgerEntry) {
|
||||||
|
const auth = useAuthStore();
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
userId: auth.user?.id,
|
||||||
ledgerId: entry.ledgerId,
|
ledgerId: entry.ledgerId,
|
||||||
entity: "entry",
|
entity: "entry",
|
||||||
entityId: entry.id,
|
entityId: entry.id,
|
||||||
|
|
@ -31,7 +130,8 @@ export const useEntryStore = defineStore("entries", {
|
||||||
await db.syncOperations.put(operation);
|
await db.syncOperations.put(operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.loadEntries();
|
await this.refreshLocalEntries();
|
||||||
|
void this.syncEntries();
|
||||||
},
|
},
|
||||||
async updateEntry(entry: LedgerEntry) {
|
async updateEntry(entry: LedgerEntry) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
@ -47,6 +147,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
};
|
};
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
userId: auth.user?.id,
|
||||||
ledgerId: updated.ledgerId,
|
ledgerId: updated.ledgerId,
|
||||||
entity: "entry",
|
entity: "entry",
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
|
|
@ -61,7 +162,8 @@ export const useEntryStore = defineStore("entries", {
|
||||||
await db.syncOperations.put(operation);
|
await db.syncOperations.put(operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.loadEntries();
|
await this.refreshLocalEntries();
|
||||||
|
void this.syncEntries();
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
async deleteEntry(entryId: string) {
|
async deleteEntry(entryId: string) {
|
||||||
|
|
@ -79,6 +181,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
};
|
};
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
userId: auth.user?.id,
|
||||||
ledgerId: deleted.ledgerId,
|
ledgerId: deleted.ledgerId,
|
||||||
entity: "entry",
|
entity: "entry",
|
||||||
entityId: deleted.id,
|
entityId: deleted.id,
|
||||||
|
|
@ -93,7 +196,8 @@ export const useEntryStore = defineStore("entries", {
|
||||||
await db.syncOperations.put(operation);
|
await db.syncOperations.put(operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.loadEntries();
|
await this.refreshLocalEntries();
|
||||||
|
void this.syncEntries();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,12 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
memberName(userId: string) {
|
memberName(userId: string) {
|
||||||
return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户";
|
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">) {
|
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "defaultCurrency">) {
|
||||||
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
|
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
|
||||||
await this.reloadLedgers();
|
await this.reloadLedgers();
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,41 @@ input:focus-visible {
|
||||||
gap: 3px;
|
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 {
|
.list-toolbar-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
@ -447,6 +482,13 @@ input:focus-visible {
|
||||||
white-space: nowrap;
|
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 {
|
.entry-copy .entry-category-label::before {
|
||||||
margin-right: 7px;
|
margin-right: 7px;
|
||||||
color: #c4cecb;
|
color: #c4cecb;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
|
CloudOff,
|
||||||
NotebookText,
|
NotebookText,
|
||||||
Trash2,
|
Trash2,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
|
|
@ -272,7 +273,10 @@ async function deleteEntry() {
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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>
|
<p v-if="errorMessage" class="entry-save-error" role="alert">{{ errorMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -578,6 +582,14 @@ async function deleteEntry() {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-sync-pending {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #b35a24;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-save-error {
|
.entry-save-error {
|
||||||
margin: 8px 16px;
|
margin: 8px 16px;
|
||||||
color: #c94635;
|
color: #c94635;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script setup lang="ts">
|
<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 { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { apiRequest, ApiError } from "../data/api";
|
import { apiRequest, ApiError } from "../data/api";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
|
|
||||||
type LedgerInvitation = {
|
type LedgerInvitation = {
|
||||||
|
|
@ -15,6 +16,7 @@ type LedgerInvitation = {
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
const ledgerStore = useLedgerStore();
|
const ledgerStore = useLedgerStore();
|
||||||
const invitation = ref<LedgerInvitation | null>(null);
|
const invitation = ref<LedgerInvitation | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -23,6 +25,11 @@ const errorMessage = ref("");
|
||||||
const key = computed(() => typeof route.query.key === "string" ? route.query.key : "");
|
const key = computed(() => typeof route.query.key === "string" ? route.query.key : "");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!key.value) {
|
||||||
|
errorMessage.value = "邀请链接不完整";
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await apiRequest<{ invitation: LedgerInvitation }>(`/api/ledger-invitations/${encodeURIComponent(key.value)}`);
|
const result = await apiRequest<{ invitation: LedgerInvitation }>(`/api/ledger-invitations/${encodeURIComponent(key.value)}`);
|
||||||
invitation.value = result.invitation;
|
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() {
|
async function accept() {
|
||||||
if (!invitation.value || accepting.value) return;
|
if (!invitation.value || accepting.value) return;
|
||||||
accepting.value = true;
|
accepting.value = true;
|
||||||
|
|
@ -59,7 +74,11 @@ async function accept() {
|
||||||
<small>{{ invitation.inviterName ? `${invitation.inviterName} 邀请你加入` : "你被邀请加入" }}</small>
|
<small>{{ invitation.inviterName ? `${invitation.inviterName} 邀请你加入` : "你被邀请加入" }}</small>
|
||||||
<h1>{{ invitation.ledgerName }}</h1>
|
<h1>{{ invitation.ledgerName }}</h1>
|
||||||
<span class="join-expiry"><Clock3 :size="14" />邀请链接 7 天内有效</span>
|
<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>
|
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="join-state error">{{ errorMessage }}</div>
|
<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 small { color:#6f7e7a; font-size:13px; }
|
||||||
.join-content h1 { margin:0; color:#1d302b; font-size:25px; letter-spacing:0; }
|
.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-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 button:disabled { opacity:.5; }
|
||||||
.join-content p,.join-state.error { color:#c74d39; font-size:12px; }
|
.join-content p,.join-state.error { color:#c74d39; font-size:12px; }
|
||||||
.join-state { color:#6e7f7a; font-size:14px; }
|
.join-state { color:#6e7f7a; font-size:14px; }
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
|
CloudOff,
|
||||||
Copy,
|
Copy,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
|
|
@ -58,6 +60,7 @@ const totals = computed(() => {
|
||||||
|
|
||||||
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
|
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
|
||||||
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
|
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
|
||||||
|
const pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
|
||||||
|
|
||||||
const dailyTotals = computed(() => {
|
const dailyTotals = computed(() => {
|
||||||
const summaries = new Map<string, { income: number; expense: number }>();
|
const summaries = new Map<string, { income: number; expense: number }>();
|
||||||
|
|
@ -279,6 +282,12 @@ async function shareLedger() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section ref="ledgerContent" class="ledger-content" aria-label="账本流水">
|
<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">
|
||||||
<div class="list-toolbar-copy">
|
<div class="list-toolbar-copy">
|
||||||
<div class="list-toolbar-heading">
|
<div class="list-toolbar-heading">
|
||||||
|
|
@ -318,6 +327,7 @@ async function shareLedger() {
|
||||||
<div class="entry-subline">
|
<div class="entry-subline">
|
||||||
<span class="entry-recorder"><CircleUserRound :size="12" />{{ ledgerStore.memberName(entry.createdBy) }}</span>
|
<span class="entry-recorder"><CircleUserRound :size="12" />{{ ledgerStore.memberName(entry.createdBy) }}</span>
|
||||||
<span class="entry-category-label">{{ categoryLabel(entry) }}</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>
|
</div>
|
||||||
<div class="entry-value">
|
<div class="entry-value">
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, ChevronRight, LibraryBig } from "@lucide/vue";
|
import type { CurrencyCode } from "@cents/domain";
|
||||||
import { computed, onMounted } from "vue";
|
import { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue";
|
||||||
|
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 { ApiError } from "../data/api";
|
||||||
import { useEntryStore } from "../stores/entries";
|
import { useEntryStore } from "../stores/entries";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
|
|
||||||
|
const colors = ["#087f72", "#3478e5", "#7559d9", "#f05d3f", "#d84486", "#15956d"];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const entryStore = useEntryStore();
|
const entryStore = useEntryStore();
|
||||||
const ledgerStore = useLedgerStore();
|
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 entryCounts = computed(() => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
|
|
@ -24,11 +33,43 @@ async function selectLedger(ledgerId: string) {
|
||||||
await ledgerStore.setCurrentLedger(ledgerId);
|
await ledgerStore.setCurrentLedger(ledgerId);
|
||||||
await router.push("/");
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="app-shell ledgers-shell">
|
<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">
|
<div class="ledgers-scroll">
|
||||||
<p class="ledgers-caption">当前账本决定流水、统计和快速记账的默认归属。</p>
|
<p class="ledgers-caption">当前账本决定流水、统计和快速记账的默认归属。</p>
|
||||||
<section class="ledger-list" aria-label="全部账本">
|
<section class="ledger-list" aria-label="全部账本">
|
||||||
|
|
@ -41,14 +82,36 @@ async function selectLedger(ledgerId: string) {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<QuickEntryHost />
|
<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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ledgers-shell { background:#f5f8f7; }
|
.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 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-scroll { height:calc(100% - 70px); overflow-y:auto; padding:0 16px 104px; }
|
||||||
.ledgers-caption { margin:16px 0 10px; color:#70807c; font-size:12px; }
|
.ledgers-caption { margin:16px 0 10px; color:#70807c; font-size:12px; }
|
||||||
.ledger-list { border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
|
.ledger-list { 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 strong { overflow:hidden; font-size:15px; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.ledger-list small { color:#7b8884; font-size:11px; }
|
.ledger-list small { color:#7b8884; font-size:11px; }
|
||||||
.current-ledger-check { color:#087f72; }
|
.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; } }
|
@media (max-width:360px) { .ledgers-scroll { padding-right:12px; padding-left:12px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const errorMessage = ref("");
|
||||||
const redirect = computed(() => typeof route.query.redirect === "string" && route.query.redirect.startsWith("/")
|
const redirect = computed(() => typeof route.query.redirect === "string" && route.query.redirect.startsWith("/")
|
||||||
? route.query.redirect
|
? route.query.redirect
|
||||||
: "/");
|
: "/");
|
||||||
|
const joiningLedger = computed(() => redirect.value.startsWith("/join-ledger?"));
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (!name.value.trim() || !password.value || submitting.value) return;
|
if (!name.value.trim() || !password.value || submitting.value) return;
|
||||||
|
|
@ -39,14 +40,18 @@ async function login() {
|
||||||
<main class="app-shell login-shell">
|
<main class="app-shell login-shell">
|
||||||
<header><span><KeyRound :size="18" /></span><strong>Cents</strong></header>
|
<header><span><KeyRound :size="18" /></span><strong>Cents</strong></header>
|
||||||
<section class="login-content">
|
<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">
|
<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><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>
|
<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>
|
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||||
<button class="login-submit" type="submit" :disabled="submitting || !name.trim() || !password"><LogIn :size="19" />{{ submitting ? "正在登录" : "登录" }}</button>
|
<button class="login-submit" type="submit" :disabled="submitting || !name.trim() || !password"><LogIn :size="19" />{{ submitting ? "正在登录" : "登录" }}</button>
|
||||||
</form>
|
</form>
|
||||||
<small>没有账号时,需要先打开应用邀请链接</small>
|
<small v-if="joiningLedger">账本邀请不能创建账户。没有账户时,请先向管理员获取应用邀请。</small>
|
||||||
|
<small v-else>没有账号时,需要先打开应用邀请链接</small>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
|
||||||
|
|
||||||
export type SyncOperation = {
|
export type SyncOperation = {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId?: string;
|
||||||
ledgerId: string;
|
ledgerId: string;
|
||||||
entity: "entry";
|
entity: "entry";
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
|
@ -37,4 +38,3 @@ export type SyncOperation = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
syncedAt: string | null;
|
syncedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,37 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
|
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() {
|
export async function initializeDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
import { initializeDatabase, pool } from "./db.js";
|
import { initializeDatabase, pool } from "./db.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -46,6 +47,7 @@ server.addHook("onSend", async (request, reply, payload) => {
|
||||||
request.url.startsWith("/api/auth/")
|
request.url.startsWith("/api/auth/")
|
||||||
|| request.url.startsWith("/api/invitations/")
|
|| request.url.startsWith("/api/invitations/")
|
||||||
|| request.url.startsWith("/api/ledger-invitations/")
|
|| request.url.startsWith("/api/ledger-invitations/")
|
||||||
|
|| request.url.startsWith("/api/sync/")
|
||||||
|| request.url === "/api/me"
|
|| request.url === "/api/me"
|
||||||
) {
|
) {
|
||||||
reply.header("Cache-Control", "no-store");
|
reply.header("Cache-Control", "no-store");
|
||||||
|
|
@ -112,6 +114,59 @@ function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Da
|
||||||
return "valid";
|
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("/health", async () => ({ ok: true, service: "cents-api" }));
|
||||||
|
|
||||||
server.get("/api/auth/session", async (request, reply) => ({ user: await currentUser(request, reply) }));
|
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 };
|
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) => {
|
server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", async (request, reply) => {
|
||||||
const user = await requireUser(request, reply);
|
const user = await requireUser(request, reply);
|
||||||
if (!user) return;
|
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()`,
|
ON CONFLICT (ledger_id, user_id) DO UPDATE SET removed_at = NULL, joined_at = now()`,
|
||||||
[invitation!.ledgerId, user.id, invitation!.role],
|
[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(
|
await client.query(
|
||||||
"UPDATE ledger_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
"UPDATE ledger_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
||||||
[user.id, invitation!.id],
|
[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) => {
|
server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (request, reply) => {
|
||||||
if (!await requireUser(request, reply)) return;
|
const user = await requireUser(request, reply);
|
||||||
return { accepted: true, operations: [] };
|
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) => {
|
server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (request, reply) => {
|
||||||
if (!await requireUser(request, reply)) return;
|
const user = await requireUser(request, reply);
|
||||||
return { cursor: null, operations: [] };
|
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);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue