Add gradient ledger themes
This commit is contained in:
parent
8627d8404a
commit
5d03fd2a11
|
|
@ -24,7 +24,7 @@ const props = defineProps<{
|
|||
ledgerId: string;
|
||||
ledgerName: string;
|
||||
personalLedgerId: string;
|
||||
ledgerOptions: Array<{ id: string; name: string; color: string; isPersonal: boolean }>;
|
||||
ledgerOptions: Array<{ id: string; name: string; gradient: string; isPersonal: boolean }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -541,7 +541,7 @@ function saveEntry() {
|
|||
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
|
||||
<div class="quick-ledger-options">
|
||||
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id), automatic: ledger.isPersonal }" :disabled="ledger.isPersonal" @click="toggleLedger(ledger.id)">
|
||||
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
|
||||
<span :style="{ background: ledger.gradient }"><BookOpen :size="17" /></span>
|
||||
<strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
|
||||
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@
|
|||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { createEntry } from "../data/entries";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
|
@ -16,7 +17,7 @@ const drawerOpen = ref(false);
|
|||
const savedToast = ref(false);
|
||||
|
||||
const ledgerOptions = computed(() =>
|
||||
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color, isPersonal: ledger.isPersonal })),
|
||||
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, gradient: ledgerTheme(ledger.theme).gradient, isPersonal: ledger.isPersonal })),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,28 @@
|
|||
import type { CurrencyCode } from "@cents/domain";
|
||||
import { ledgerThemeAccents, type CurrencyCode, type LedgerThemeId } from "@cents/domain";
|
||||
|
||||
export const ledgerThemes: ReadonlyArray<{
|
||||
id: LedgerThemeId;
|
||||
name: string;
|
||||
accent: string;
|
||||
gradient: string;
|
||||
}> = [
|
||||
{ id: "jade", name: "翡翠", accent: ledgerThemeAccents.jade, gradient: "linear-gradient(135deg, #118b79 0%, #087f72 48%, #285765 100%)" },
|
||||
{ id: "ocean", name: "海湾", accent: ledgerThemeAccents.ocean, gradient: "linear-gradient(135deg, #167b93 0%, #3459a4 52%, #4b3f7f 100%)" },
|
||||
{ id: "coral", name: "晚霞", accent: ledgerThemeAccents.coral, gradient: "linear-gradient(135deg, #c94f45 0%, #ad4869 54%, #6e4d82 100%)" },
|
||||
{ id: "berry", name: "莓果", accent: ledgerThemeAccents.berry, gradient: "linear-gradient(135deg, #a84378 0%, #71468d 52%, #40578a 100%)" },
|
||||
{ id: "meadow", name: "森野", accent: ledgerThemeAccents.meadow, gradient: "linear-gradient(135deg, #25805f 0%, #4e794b 54%, #766b35 100%)" },
|
||||
{ id: "graphite", name: "暮色", accent: ledgerThemeAccents.graphite, gradient: "linear-gradient(135deg, #35585d 0%, #4e556e 52%, #303f55 100%)" },
|
||||
];
|
||||
|
||||
export function ledgerTheme(themeId?: string) {
|
||||
return ledgerThemes.find((theme) => theme.id === themeId) ?? ledgerThemes[0];
|
||||
}
|
||||
|
||||
export type LedgerRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
theme: LedgerThemeId;
|
||||
defaultCurrency: CurrencyCode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -31,6 +50,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
|
|||
id: "local-ledger",
|
||||
name: "家庭日常",
|
||||
color: "#087f72",
|
||||
theme: "jade",
|
||||
defaultCurrency: "CNY",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -41,6 +61,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
|
|||
id: "travel-ledger",
|
||||
name: "旅行账本",
|
||||
color: "#3478e5",
|
||||
theme: "ocean",
|
||||
defaultCurrency: "CNY",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -112,13 +112,13 @@ export const useLedgerStore = defineStore("ledgers", {
|
|||
memberName(userId: string) {
|
||||
return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户";
|
||||
},
|
||||
async createLedger(input: Pick<LedgerRecord, "name" | "color" | "defaultCurrency">) {
|
||||
async createLedger(input: Pick<LedgerRecord, "name" | "color" | "theme" | "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" | "theme" | "defaultCurrency">) {
|
||||
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
|
||||
await this.reloadLedgers();
|
||||
return this.ledgers.find((ledger) => ledger.id === input.id)!;
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ input:focus-visible {
|
|||
z-index: 1;
|
||||
min-height: 180px;
|
||||
padding: max(18px, env(safe-area-inset-top)) 18px 20px;
|
||||
background: #087f72;
|
||||
background: var(--ledger-gradient, #087f72);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ input:focus-visible {
|
|||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 2px solid #087f72;
|
||||
border: 2px solid var(--ledger-accent, #087f72);
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
findCategoryPath,
|
||||
type Category,
|
||||
} from "../data/categories";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
|
|
@ -349,7 +350,7 @@ async function deleteEntry() {
|
|||
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
|
||||
<div class="quick-ledger-options">
|
||||
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" :class="{ selected: ledgerIds.includes(ledger.id), automatic: ledger.isPersonal }" :disabled="ledger.isPersonal" @click="toggleLedger(ledger.id)">
|
||||
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
|
||||
<span :style="{ background: ledgerTheme(ledger.theme).gradient }"><BookOpen :size="17" /></span>
|
||||
<strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
|
||||
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { LedgerThemeId } from "@cents/domain";
|
||||
import { ArrowLeft, Check } from "@lucide/vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
const colors = ["#087f72", "#3478e5", "#7559d9", "#f05d3f", "#d84486", "#15956d"];
|
||||
const router = useRouter();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const name = ref("");
|
||||
const color = ref(colors[0]);
|
||||
const themeId = ref<LedgerThemeId>("jade");
|
||||
const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY");
|
||||
const saving = ref(false);
|
||||
|
||||
|
|
@ -20,14 +21,15 @@ onMounted(async () => {
|
|||
}
|
||||
if (!ledgerStore.currentLedger) return;
|
||||
name.value = ledgerStore.currentLedger.name;
|
||||
color.value = ledgerStore.currentLedger.color;
|
||||
themeId.value = ledgerStore.currentLedger.theme;
|
||||
currency.value = ledgerStore.currentLedger.defaultCurrency;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!ledgerStore.currentLedger || !name.value.trim()) return;
|
||||
saving.value = true;
|
||||
await ledgerStore.updateLedger({ id: ledgerStore.currentLedger.id, name: name.value, color: color.value, defaultCurrency: currency.value });
|
||||
const theme = ledgerTheme(themeId.value);
|
||||
await ledgerStore.updateLedger({ id: ledgerStore.currentLedger.id, name: name.value, color: theme.accent, theme: theme.id, defaultCurrency: currency.value });
|
||||
saving.value = false;
|
||||
await router.back();
|
||||
}
|
||||
|
|
@ -45,9 +47,9 @@ async function save() {
|
|||
<label><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>
|
||||
</section>
|
||||
<section class="color-setting">
|
||||
<span>账本颜色</span>
|
||||
<span>账本主题</span>
|
||||
<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>
|
||||
<button v-for="theme in ledgerThemes" :key="theme.id" type="button" :class="{ active: themeId === theme.id }" :style="{ background: theme.gradient }" :aria-label="`选择${theme.name}主题`" :title="theme.name" @click="themeId = theme.id"><Check v-if="themeId === theme.id" :size="17" /></button>
|
||||
</div>
|
||||
</section>
|
||||
<button class="save-ledger-settings" type="submit" :disabled="saving || !name.trim()"><Check :size="19" />{{ saving ? "保存中" : "保存设置" }}</button>
|
||||
|
|
@ -67,8 +69,8 @@ async function save() {
|
|||
.settings-form label span,.color-setting > span { color:#7b8884; font-size:11px; }
|
||||
.settings-form input,.settings-form select { width:100%; border:0; padding:0; outline:0; background:transparent; color:#26342f; font-size:15px; }
|
||||
.color-setting { margin-top:12px; padding-top:14px!important; padding-bottom:16px!important; }
|
||||
.color-setting > div { display:flex; gap:12px; margin-top:12px; }
|
||||
.color-setting 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; }
|
||||
.color-setting > div { display:grid; grid-template-columns:repeat(6, 38px); justify-content:space-between; gap:4px; margin-top:12px; }
|
||||
.color-setting button { width:38px; height:34px; display:grid; place-items:center; border:3px solid #fff; border-radius:8px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
|
||||
.color-setting button.active { box-shadow:0 0 0 2px #263b36; }
|
||||
.save-ledger-settings { position:absolute; right:16px; bottom:16px; left:16px; height:48px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||
.save-ledger-settings:disabled { opacity:.55; }
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useRouter } from "vue-router";
|
|||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { apiRequest, ApiError } from "../data/api";
|
||||
import { findCategory, findCategoryPath } from "../data/categories";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
|
|
@ -72,6 +73,7 @@ const expenseText = computed(() => formatMoney(totals.value.expense));
|
|||
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 currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
|
||||
|
||||
const dailyTotals = computed(() => {
|
||||
const summaries = new Map<string, { income: number; expense: number }>();
|
||||
|
|
@ -252,7 +254,7 @@ async function shareLedger() {
|
|||
|
||||
<template>
|
||||
<main class="app-shell">
|
||||
<header class="ledger-header">
|
||||
<header class="ledger-header" :style="{ '--ledger-gradient': currentTheme.gradient, '--ledger-accent': currentTheme.accent }">
|
||||
<div class="top-actions">
|
||||
<button v-if="!ledgerStore.currentLedger?.isPersonal" class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
|
||||
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { CurrencyCode } from "@cents/domain";
|
||||
import type { CurrencyCode, LedgerThemeId } 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 { ledgerTheme, ledgerThemes } from "../data/ledgers";
|
||||
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 themeId = ref<LedgerThemeId>("jade");
|
||||
const currency = ref<CurrencyCode>("CNY");
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
|
@ -38,7 +38,7 @@ async function selectLedger(ledgerId: string) {
|
|||
|
||||
function openCreate() {
|
||||
name.value = "";
|
||||
color.value = colors[ledgerStore.ledgers.length % colors.length];
|
||||
themeId.value = ledgerThemes[ledgerStore.ledgers.length % ledgerThemes.length].id;
|
||||
currency.value = ledgerStore.currentLedger?.defaultCurrency ?? "CNY";
|
||||
errorMessage.value = "";
|
||||
createOpen.value = true;
|
||||
|
|
@ -54,7 +54,8 @@ async function createLedger() {
|
|||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await ledgerStore.createLedger({ name: trimmedName, color: color.value, defaultCurrency: currency.value });
|
||||
const theme = ledgerTheme(themeId.value);
|
||||
await ledgerStore.createLedger({ name: trimmedName, color: theme.accent, theme: theme.id, defaultCurrency: currency.value });
|
||||
createOpen.value = false;
|
||||
await router.push("/");
|
||||
} catch (error) {
|
||||
|
|
@ -76,7 +77,7 @@ async function createLedger() {
|
|||
<p class="ledgers-caption">当前账本决定流水、统计和快速记账的默认归属。</p>
|
||||
<section class="ledger-list" aria-label="全部账本">
|
||||
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)">
|
||||
<span class="ledger-list-icon" :style="{ color: ledger.color, background: `${ledger.color}18` }"><LibraryBig :size="22" /></span>
|
||||
<span class="ledger-list-icon" :style="{ background: ledgerTheme(ledger.theme).gradient }"><LibraryBig :size="22" /></span>
|
||||
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.isPersonal ? "仅自己可见" : ledger.defaultCurrency }}</small></span>
|
||||
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
|
||||
<ChevronRight v-else :size="19" />
|
||||
|
|
@ -97,8 +98,8 @@ async function createLedger() {
|
|||
<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>
|
||||
<legend>账本主题</legend>
|
||||
<div><button v-for="theme in ledgerThemes" :key="theme.id" type="button" :class="{ active: themeId === theme.id }" :style="{ background: theme.gradient }" :aria-label="`选择${theme.name}主题`" :title="theme.name" @click="themeId = theme.id"><Check v-if="themeId === theme.id" :size="17" /></button></div>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
|
@ -119,7 +120,7 @@ async function createLedger() {
|
|||
.ledger-list { border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
|
||||
.ledger-list button { width:100%; min-height:72px; display:grid; grid-template-columns:44px minmax(0,1fr) 22px; align-items:center; gap:11px; border:0; border-bottom:1px solid #e6eeec; padding:8px 12px; background:#fff; color:#26342f; text-align:left; }
|
||||
.ledger-list button:last-child { border-bottom:0; }
|
||||
.ledger-list-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:9px; }
|
||||
.ledger-list-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:9px; color:#fff; box-shadow:0 4px 10px rgba(38,59,54,.16); }
|
||||
.ledger-list button > span:nth-child(2) { min-width:0; display:grid; gap:2px; }
|
||||
.ledger-list strong { overflow:hidden; font-size:15px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ledger-list small { color:#7b8884; font-size:11px; }
|
||||
|
|
@ -137,8 +138,8 @@ async function createLedger() {
|
|||
.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 > div { display:grid; grid-template-columns:repeat(6, 38px); justify-content:space-between; gap:4px; margin-top:10px; }
|
||||
.create-ledger-colors button { width:38px; height:34px; display:grid; place-items:center; border:3px solid #fff; border-radius:8px; color:#fff; box-shadow:0 0 0 1px #d8e4e1; }
|
||||
.create-ledger-colors 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; }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ export type CurrencyCode = "CNY" | "USD" | "EUR" | "JPY" | "HKD";
|
|||
|
||||
export type ExchangeRateSource = "manual" | "system";
|
||||
|
||||
export const ledgerThemeAccents = {
|
||||
jade: "#087f72",
|
||||
ocean: "#3478e5",
|
||||
coral: "#c94f45",
|
||||
berry: "#a84378",
|
||||
meadow: "#2f8060",
|
||||
graphite: "#526a78",
|
||||
} as const;
|
||||
|
||||
export type LedgerThemeId = keyof typeof ledgerThemeAccents;
|
||||
|
||||
export type LedgerEntry = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
|
|
|
|||
|
|
@ -27,12 +27,22 @@ CREATE TABLE IF NOT EXISTS ledgers (
|
|||
id text PRIMARY KEY,
|
||||
name varchar(40) NOT NULL,
|
||||
color varchar(16) NOT NULL DEFAULT '#087f72',
|
||||
theme varchar(24) NOT NULL DEFAULT 'jade',
|
||||
default_currency varchar(3) NOT NULL DEFAULT 'CNY',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS theme varchar(24) NOT NULL DEFAULT 'jade';
|
||||
UPDATE ledgers SET theme = CASE lower(color)
|
||||
WHEN '#3478e5' THEN 'ocean'
|
||||
WHEN '#7559d9' THEN 'berry'
|
||||
WHEN '#f05d3f' THEN 'coral'
|
||||
WHEN '#d84486' THEN 'berry'
|
||||
WHEN '#15956d' THEN 'meadow'
|
||||
ELSE 'jade'
|
||||
END WHERE theme = 'jade' AND lower(color) <> '#087f72';
|
||||
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS personal_owner_id text REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ledgers_personal_owner_unique
|
||||
ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL;
|
||||
|
|
|
|||
|
|
@ -1,7 +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 { ledgerThemeAccents, type LedgerEntry, type LedgerThemeId, type SyncOperation } from "@cents/domain";
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
|
||||
import { initializeDatabase, pool } from "./db.js";
|
||||
|
|
@ -117,6 +117,16 @@ function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Da
|
|||
|
||||
const currencies = new Set(["CNY", "USD", "EUR", "JPY", "HKD"]);
|
||||
|
||||
function validLedgerTheme(value: unknown): value is LedgerThemeId {
|
||||
return typeof value === "string" && Object.hasOwn(ledgerThemeAccents, value);
|
||||
}
|
||||
|
||||
function ledgerThemeFromColor(color: unknown): LedgerThemeId {
|
||||
if (typeof color !== "string") return "jade";
|
||||
return (Object.entries(ledgerThemeAccents) as Array<[LedgerThemeId, string]>)
|
||||
.find(([, accent]) => accent.toLowerCase() === color.toLowerCase())?.[0] ?? "jade";
|
||||
}
|
||||
|
||||
function validDate(value: unknown) {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
|
@ -231,7 +241,7 @@ server.get("/api/ledgers", async (request, reply) => {
|
|||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
const result = await pool.query(
|
||||
`SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency",
|
||||
`SELECT l.id, l.name, l.color, l.theme, l.default_currency AS "defaultCurrency",
|
||||
l.created_at AS "createdAt", l.updated_at AS "updatedAt",
|
||||
l.archived_at AS "archivedAt", m.role,
|
||||
(l.personal_owner_id IS NOT NULL) AS "isPersonal"
|
||||
|
|
@ -243,13 +253,17 @@ server.get("/api/ledgers", async (request, reply) => {
|
|||
return { ledgers: result.rows };
|
||||
});
|
||||
|
||||
server.post<{ Body: { name?: string; color?: string; defaultCurrency?: string } }>(
|
||||
server.post<{ Body: { name?: string; color?: string; theme?: 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";
|
||||
if (request.body.theme !== undefined && !validLedgerTheme(request.body.theme)) {
|
||||
return reply.code(400).send({ error: "账本主题无效" });
|
||||
}
|
||||
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
|
||||
const color = ledgerThemeAccents[theme];
|
||||
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: "账本颜色无效" });
|
||||
|
|
@ -262,11 +276,11 @@ server.post<{ Body: { name?: string; color?: string; defaultCurrency?: string }
|
|||
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",
|
||||
`INSERT INTO ledgers (id, name, color, theme, default_currency)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
|
||||
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
|
||||
[ledgerId, name, color, defaultCurrency],
|
||||
[ledgerId, name, color, theme, defaultCurrency],
|
||||
);
|
||||
await client.query(
|
||||
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||
|
|
@ -299,7 +313,7 @@ server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", a
|
|||
|
||||
server.patch<{
|
||||
Params: { ledgerId: string };
|
||||
Body: { name?: string; color?: string; defaultCurrency?: string };
|
||||
Body: { name?: string; color?: string; theme?: string; defaultCurrency?: string };
|
||||
}>("/api/ledgers/:ledgerId", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
|
|
@ -312,12 +326,17 @@ server.patch<{
|
|||
);
|
||||
const name = request.body.name?.trim() ?? "";
|
||||
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" });
|
||||
if (request.body.theme !== undefined && !validLedgerTheme(request.body.theme)) {
|
||||
return reply.code(400).send({ error: "账本主题无效" });
|
||||
}
|
||||
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
|
||||
const color = ledgerThemeAccents[theme];
|
||||
const result = await pool.query(
|
||||
`UPDATE ledgers SET name = $1, color = $2, default_currency = $3, updated_at = now()
|
||||
WHERE id = $4
|
||||
RETURNING id, name, color, default_currency AS "defaultCurrency",
|
||||
`UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
|
||||
WHERE id = $5
|
||||
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
|
||||
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
|
||||
[personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
|
||||
[personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, color, theme, request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
|
||||
);
|
||||
return { ledger: result.rows[0] };
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue