Add gradient ledger themes

This commit is contained in:
openclaw 2026-07-23 00:27:07 +08:00
parent 8627d8404a
commit 5d03fd2a11
12 changed files with 110 additions and 42 deletions

View File

@ -24,7 +24,7 @@ const props = defineProps<{
ledgerId: string; ledgerId: string;
ledgerName: string; ledgerName: string;
personalLedgerId: 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<{ 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> <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"> <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)"> <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> <strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" /> <Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
</button> </button>

View File

@ -3,6 +3,7 @@ import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { createEntry } from "../data/entries"; import { createEntry } from "../data/entries";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { useLedgerStore } from "../stores/ledgers"; import { useLedgerStore } from "../stores/ledgers";
@ -16,7 +17,7 @@ const drawerOpen = ref(false);
const savedToast = ref(false); const savedToast = ref(false);
const ledgerOptions = computed(() => const ledgerOptions = computed(() =>
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, 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 () => { onMounted(async () => {

View File

@ -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 = { export type LedgerRecord = {
id: string; id: string;
name: string; name: string;
color: string; color: string;
theme: LedgerThemeId;
defaultCurrency: CurrencyCode; defaultCurrency: CurrencyCode;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -31,6 +50,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
id: "local-ledger", id: "local-ledger",
name: "家庭日常", name: "家庭日常",
color: "#087f72", color: "#087f72",
theme: "jade",
defaultCurrency: "CNY", defaultCurrency: "CNY",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -41,6 +61,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
id: "travel-ledger", id: "travel-ledger",
name: "旅行账本", name: "旅行账本",
color: "#3478e5", color: "#3478e5",
theme: "ocean",
defaultCurrency: "CNY", defaultCurrency: "CNY",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@ -112,13 +112,13 @@ 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">) { async createLedger(input: Pick<LedgerRecord, "name" | "color" | "theme" | "defaultCurrency">) {
const result = await apiRequest<{ ledger: LedgerRecord }>("/api/ledgers", { method: "POST", body: input }); const result = await apiRequest<{ ledger: LedgerRecord }>("/api/ledgers", { method: "POST", body: input });
await this.reloadLedgers(); await this.reloadLedgers();
await this.setCurrentLedger(result.ledger.id); await this.setCurrentLedger(result.ledger.id);
return result.ledger; 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 apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
await this.reloadLedgers(); await this.reloadLedgers();
return this.ledgers.find((ledger) => ledger.id === input.id)!; return this.ledgers.find((ledger) => ledger.id === input.id)!;

View File

@ -75,7 +75,7 @@ input:focus-visible {
z-index: 1; z-index: 1;
min-height: 180px; min-height: 180px;
padding: max(18px, env(safe-area-inset-top)) 18px 20px; padding: max(18px, env(safe-area-inset-top)) 18px 20px;
background: #087f72; background: var(--ledger-gradient, #087f72);
color: #ffffff; color: #ffffff;
} }
@ -112,7 +112,7 @@ input:focus-visible {
display: grid; display: grid;
flex: 0 0 auto; flex: 0 0 auto;
place-items: center; place-items: center;
border: 2px solid #087f72; border: 2px solid var(--ledger-accent, #087f72);
border-radius: 50%; border-radius: 50%;
color: #ffffff; color: #ffffff;
font-size: 10px; font-size: 10px;

View File

@ -22,6 +22,7 @@ import {
findCategoryPath, findCategoryPath,
type Category, type Category,
} from "../data/categories"; } from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; 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> <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"> <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)"> <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> <strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" /> <Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
</button> </button>

View File

@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LedgerThemeId } from "@cents/domain";
import { ArrowLeft, Check } from "@lucide/vue"; import { ArrowLeft, Check } from "@lucide/vue";
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
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 ledgerStore = useLedgerStore(); const ledgerStore = useLedgerStore();
const name = ref(""); const name = ref("");
const color = ref(colors[0]); const themeId = ref<LedgerThemeId>("jade");
const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY"); const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY");
const saving = ref(false); const saving = ref(false);
@ -20,14 +21,15 @@ onMounted(async () => {
} }
if (!ledgerStore.currentLedger) return; if (!ledgerStore.currentLedger) return;
name.value = ledgerStore.currentLedger.name; name.value = ledgerStore.currentLedger.name;
color.value = ledgerStore.currentLedger.color; themeId.value = ledgerStore.currentLedger.theme;
currency.value = ledgerStore.currentLedger.defaultCurrency; currency.value = ledgerStore.currentLedger.defaultCurrency;
}); });
async function save() { async function save() {
if (!ledgerStore.currentLedger || !name.value.trim()) return; if (!ledgerStore.currentLedger || !name.value.trim()) return;
saving.value = true; 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; saving.value = false;
await router.back(); 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> <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>
<section class="color-setting"> <section class="color-setting">
<span>账本颜色</span> <span>账本主题</span>
<div> <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> </div>
</section> </section>
<button class="save-ledger-settings" type="submit" :disabled="saving || !name.trim()"><Check :size="19" />{{ saving ? "保存中" : "保存设置" }}</button> <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 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; } .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 { margin-top:12px; padding-top:14px!important; padding-bottom:16px!important; }
.color-setting > div { display:flex; gap:12px; margin-top:12px; } .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:38px; display:grid; place-items:center; border:3px solid #fff; border-radius:50%; color:#fff; box-shadow:0 0 0 1px #d8e4e1; } .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; } .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 { 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; } .save-ledger-settings:disabled { opacity:.55; }

View File

@ -19,6 +19,7 @@ import { useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue"; import QuickEntryHost from "../components/QuickEntryHost.vue";
import { apiRequest, ApiError } from "../data/api"; import { apiRequest, ApiError } from "../data/api";
import { findCategory, findCategoryPath } from "../data/categories"; import { findCategory, findCategoryPath } from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries"; import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers"; 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 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 pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
const dailyTotals = computed(() => { const dailyTotals = computed(() => {
const summaries = new Map<string, { income: number; expense: number }>(); const summaries = new Map<string, { income: number; expense: number }>();
@ -252,7 +254,7 @@ async function shareLedger() {
<template> <template>
<main class="app-shell"> <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"> <div class="top-actions">
<button v-if="!ledgerStore.currentLedger?.isPersonal" class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet"> <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"> <span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">

View File

@ -1,20 +1,20 @@
<script setup lang="ts"> <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 { Check, ChevronRight, LibraryBig, Plus, X } from "@lucide/vue";
import { computed, onMounted, ref } from "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 { ApiError } from "../data/api";
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
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 createOpen = ref(false);
const name = ref(""); const name = ref("");
const color = ref(colors[0]); const themeId = ref<LedgerThemeId>("jade");
const currency = ref<CurrencyCode>("CNY"); const currency = ref<CurrencyCode>("CNY");
const saving = ref(false); const saving = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
@ -38,7 +38,7 @@ async function selectLedger(ledgerId: string) {
function openCreate() { function openCreate() {
name.value = ""; 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"; currency.value = ledgerStore.currentLedger?.defaultCurrency ?? "CNY";
errorMessage.value = ""; errorMessage.value = "";
createOpen.value = true; createOpen.value = true;
@ -54,7 +54,8 @@ async function createLedger() {
saving.value = true; saving.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { 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; createOpen.value = false;
await router.push("/"); await router.push("/");
} catch (error) { } catch (error) {
@ -76,7 +77,7 @@ async function createLedger() {
<p class="ledgers-caption">当前账本决定流水统计和快速记账的默认归属</p> <p class="ledgers-caption">当前账本决定流水统计和快速记账的默认归属</p>
<section class="ledger-list" aria-label="全部账本"> <section class="ledger-list" aria-label="全部账本">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)"> <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> <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" /> <Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
<ChevronRight v-else :size="19" /> <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-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> <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"> <fieldset class="create-ledger-colors">
<legend>账本颜色</legend> <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> <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> </fieldset>
<p v-if="errorMessage" class="create-ledger-error" role="alert">{{ errorMessage }}</p> <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> <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 { 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 { 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 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 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 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; }
@ -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-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 { margin:0; border:0; border-top:1px solid #e2ebe8; padding:12px 2px 15px; }
.create-ledger-colors legend { padding:0; } .create-ledger-colors legend { padding:0; }
.create-ledger-colors > div { display:flex; gap:12px; margin-top:10px; } .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: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 { 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-colors button.active { box-shadow:0 0 0 2px #263b36; }
.create-ledger-error { margin:0 0 10px; color:#b83232; font-size:12px; } .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 { 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; }

View File

@ -4,6 +4,17 @@ export type CurrencyCode = "CNY" | "USD" | "EUR" | "JPY" | "HKD";
export type ExchangeRateSource = "manual" | "system"; 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 = { export type LedgerEntry = {
id: string; id: string;
ownerId: string; ownerId: string;

View File

@ -27,12 +27,22 @@ CREATE TABLE IF NOT EXISTS ledgers (
id text PRIMARY KEY, id text PRIMARY KEY,
name varchar(40) NOT NULL, name varchar(40) NOT NULL,
color varchar(16) NOT NULL DEFAULT '#087f72', color varchar(16) NOT NULL DEFAULT '#087f72',
theme varchar(24) NOT NULL DEFAULT 'jade',
default_currency varchar(3) NOT NULL DEFAULT 'CNY', default_currency varchar(3) NOT NULL DEFAULT 'CNY',
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz 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; 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 CREATE UNIQUE INDEX IF NOT EXISTS ledgers_personal_owner_unique
ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL; ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL;

View File

@ -1,7 +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 { ledgerThemeAccents, type LedgerEntry, type LedgerThemeId, type SyncOperation } from "@cents/domain";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js"; import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
import { initializeDatabase, pool } from "./db.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"]); 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) { function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value)); 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); const user = await requireUser(request, reply);
if (!user) return; if (!user) return;
const result = await pool.query( 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.created_at AS "createdAt", l.updated_at AS "updatedAt",
l.archived_at AS "archivedAt", m.role, l.archived_at AS "archivedAt", m.role,
(l.personal_owner_id IS NOT NULL) AS "isPersonal" (l.personal_owner_id IS NOT NULL) AS "isPersonal"
@ -243,13 +253,17 @@ server.get("/api/ledgers", async (request, reply) => {
return { ledgers: result.rows }; 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", "/api/ledgers",
async (request, reply) => { async (request, reply) => {
const user = await requireUser(request, reply); const user = await requireUser(request, reply);
if (!user) return; if (!user) return;
const name = request.body.name?.trim() ?? ""; 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"; const defaultCurrency = request.body.defaultCurrency ?? "CNY";
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称应为 1 至 40 个字符" }); 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 (!/^#[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"); await client.query("BEGIN");
const ledgerId = createId(); const ledgerId = createId();
const result = await client.query( const result = await client.query(
`INSERT INTO ledgers (id, name, color, default_currency) `INSERT INTO ledgers (id, name, color, theme, default_currency)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, color, default_currency AS "defaultCurrency", RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`, created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[ledgerId, name, color, defaultCurrency], [ledgerId, name, color, theme, defaultCurrency],
); );
await client.query( await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')", "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<{ server.patch<{
Params: { ledgerId: string }; 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) => { }>("/api/ledgers/:ledgerId", async (request, reply) => {
const user = await requireUser(request, reply); const user = await requireUser(request, reply);
if (!user) return; if (!user) return;
@ -312,12 +326,17 @@ server.patch<{
); );
const name = request.body.name?.trim() ?? ""; const name = request.body.name?.trim() ?? "";
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" }); 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( const result = await pool.query(
`UPDATE ledgers SET name = $1, color = $2, default_currency = $3, updated_at = now() `UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
WHERE id = $4 WHERE id = $5
RETURNING id, name, color, default_currency AS "defaultCurrency", RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`, 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] }; return { ledger: result.rows[0] };
}); });