Add gradient ledger themes
This commit is contained in:
parent
8627d8404a
commit
5d03fd2a11
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)!;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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] };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue