Improve statistics and category options
This commit is contained in:
parent
5d03fd2a11
commit
322ee737fd
|
|
@ -2,23 +2,45 @@ import type { EntryType } from "@cents/domain";
|
|||
import {
|
||||
Banknote,
|
||||
Bike,
|
||||
BookOpen,
|
||||
BriefcaseBusiness,
|
||||
BusFront,
|
||||
CarTaxiFront,
|
||||
ChartNoAxesCombined,
|
||||
Clapperboard,
|
||||
Coffee,
|
||||
CookingPot,
|
||||
Cookie,
|
||||
CupSoda,
|
||||
FileText,
|
||||
Fuel,
|
||||
Gamepad2,
|
||||
Gift,
|
||||
GraduationCap,
|
||||
HandCoins,
|
||||
HeartPulse,
|
||||
House,
|
||||
KeyRound,
|
||||
Music2,
|
||||
Package,
|
||||
ParkingCircle,
|
||||
Plane,
|
||||
ReceiptText,
|
||||
RotateCcw,
|
||||
School,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
ShoppingBasket,
|
||||
Shirt,
|
||||
Smartphone,
|
||||
Soup,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
TrainFront,
|
||||
Utensils,
|
||||
WalletCards,
|
||||
Wifi,
|
||||
Wrench,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "@lucide/vue";
|
||||
|
||||
|
|
@ -40,7 +62,7 @@ export type EntryTypeOption = {
|
|||
};
|
||||
|
||||
export const entryTypes: EntryTypeOption[] = [
|
||||
{ id: "expense", label: "支出", color: "#ef5b3f", tint: "#fff0ec", icon: WalletCards },
|
||||
{ id: "expense", label: "支出", color: "#ef5b3f", tint: "#fff0ec", icon: ReceiptText },
|
||||
{ id: "income", label: "收入", color: "#16966f", tint: "#e9faf4", icon: Banknote },
|
||||
];
|
||||
|
||||
|
|
@ -56,6 +78,8 @@ export const categories: Record<EntryType, Category[]> = {
|
|||
{ id: "takeout", label: "外卖", color: "#f06a34", tint: "#fff0e8", icon: Soup },
|
||||
{ id: "restaurant", label: "堂食", color: "#e9572e", tint: "#ffede7", icon: CookingPot },
|
||||
{ id: "coffee", label: "咖啡", color: "#9b6a48", tint: "#f8eee6", icon: Coffee },
|
||||
{ id: "snacks", label: "零食", color: "#c98535", tint: "#fff4e5", icon: Cookie },
|
||||
{ id: "drinks", label: "饮品", color: "#2d8f9e", tint: "#eaf8fa", icon: CupSoda },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -67,7 +91,10 @@ export const categories: Record<EntryType, Category[]> = {
|
|||
children: [
|
||||
{ id: "taxi", label: "打车", color: "#3478e5", tint: "#edf4ff", icon: CarTaxiFront },
|
||||
{ id: "public-transit", label: "公交地铁", color: "#2563c7", tint: "#edf4ff", icon: Ticket },
|
||||
{ id: "train", label: "火车", color: "#4869b1", tint: "#eef2fb", icon: TrainFront },
|
||||
{ id: "cycling", label: "骑行", color: "#168c87", tint: "#e8f8f6", icon: Bike },
|
||||
{ id: "fuel", label: "加油", color: "#6f6b58", tint: "#f3f2ed", icon: Fuel },
|
||||
{ id: "parking", label: "停车", color: "#4f7190", tint: "#edf3f7", icon: ParkingCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -83,18 +110,72 @@ export const categories: Record<EntryType, Category[]> = {
|
|||
],
|
||||
},
|
||||
{ id: "shopping", label: "购物", color: "#d84486", tint: "#fff0f7", icon: ShoppingBag },
|
||||
{
|
||||
id: "housing",
|
||||
label: "住房",
|
||||
color: "#387b70",
|
||||
tint: "#eaf6f3",
|
||||
icon: House,
|
||||
children: [
|
||||
{ id: "rent", label: "房租房贷", color: "#387b70", tint: "#eaf6f3", icon: KeyRound },
|
||||
{ id: "utilities", label: "水电燃气", color: "#b27a22", tint: "#fff7e7", icon: Zap },
|
||||
{ id: "maintenance", label: "维修", color: "#697970", tint: "#f0f4f2", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entertainment",
|
||||
label: "娱乐",
|
||||
color: "#7958b3",
|
||||
tint: "#f3effb",
|
||||
icon: Gamepad2,
|
||||
children: [
|
||||
{ id: "games", label: "游戏", color: "#7958b3", tint: "#f3effb", icon: Gamepad2 },
|
||||
{ id: "movies", label: "电影", color: "#a74d65", tint: "#faeef2", icon: Clapperboard },
|
||||
{ id: "music", label: "音乐", color: "#4f70aa", tint: "#edf2fa", icon: Music2 },
|
||||
],
|
||||
},
|
||||
{ id: "health", label: "医疗", color: "#df3f5f", tint: "#fff0f2", icon: HeartPulse },
|
||||
{
|
||||
id: "education",
|
||||
label: "教育",
|
||||
color: "#4869b1",
|
||||
tint: "#edf2fb",
|
||||
icon: GraduationCap,
|
||||
children: [
|
||||
{ id: "books", label: "书籍", color: "#6b5c4c", tint: "#f5f0ea", icon: BookOpen },
|
||||
{ id: "courses", label: "课程", color: "#4869b1", tint: "#edf2fb", icon: School },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bills",
|
||||
label: "账单",
|
||||
color: "#526a78",
|
||||
tint: "#edf2f4",
|
||||
icon: FileText,
|
||||
children: [
|
||||
{ id: "mobile", label: "话费", color: "#526a78", tint: "#edf2f4", icon: Smartphone },
|
||||
{ id: "internet", label: "宽带网络", color: "#3478a5", tint: "#edf5f9", icon: Wifi },
|
||||
{ id: "insurance", label: "保险", color: "#477866", tint: "#edf6f2", icon: ShieldCheck },
|
||||
],
|
||||
},
|
||||
{ id: "clothing", label: "服饰", color: "#b14e79", tint: "#faeef4", icon: Shirt },
|
||||
{ id: "beauty", label: "美容", color: "#b75e91", tint: "#fbf0f6", icon: Sparkles },
|
||||
{ id: "delivery", label: "快递", color: "#9a6b35", tint: "#f8f1e8", icon: Package },
|
||||
{ id: "travel", label: "旅行", color: "#7559d9", tint: "#f2efff", icon: Plane },
|
||||
],
|
||||
income: [
|
||||
{ id: "salary", label: "工资", color: "#15956d", tint: "#e8faf3", icon: BriefcaseBusiness },
|
||||
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
|
||||
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: GraduationCap },
|
||||
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins },
|
||||
{ id: "investment", label: "投资收益", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined },
|
||||
{ id: "refund", label: "退款报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw },
|
||||
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
|
||||
],
|
||||
};
|
||||
|
||||
export function findCategory(categoryId: string): Category | undefined {
|
||||
export function findCategory(categoryId: string): Category | EntryTypeOption | undefined {
|
||||
const entryType = entryTypes.find((type) => type.id === categoryId);
|
||||
if (entryType) return entryType;
|
||||
for (const group of Object.values(categories)) {
|
||||
for (const category of group) {
|
||||
if (category.id === categoryId) return category;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ export const ledgerThemes: ReadonlyArray<{
|
|||
{ 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%)" },
|
||||
{ id: "sunrise", name: "晨曦", accent: ledgerThemeAccents.sunrise, gradient: "linear-gradient(135deg, #d18a45 0%, #b45f4a 50%, #82506f 100%)" },
|
||||
{ id: "lagoon", name: "泻湖", accent: ledgerThemeAccents.lagoon, gradient: "linear-gradient(135deg, #15988b 0%, #177f8f 50%, #3f5f91 100%)" },
|
||||
{ id: "iris", name: "鸢尾", accent: ledgerThemeAccents.iris, gradient: "linear-gradient(135deg, #5b70b8 0%, #7061b5 50%, #a0508d 100%)" },
|
||||
{ id: "sky", name: "晴空", accent: ledgerThemeAccents.sky, gradient: "linear-gradient(135deg, #2592a6 0%, #327ba0 52%, #49639b 100%)" },
|
||||
{ id: "olive", name: "山岚", accent: ledgerThemeAccents.olive, gradient: "linear-gradient(135deg, #4c8168 0%, #64784a 52%, #8a703e 100%)" },
|
||||
{ id: "midnight", name: "夜航", accent: ledgerThemeAccents.midnight, gradient: "linear-gradient(135deg, #315d70 0%, #47577f 52%, #684b78 100%)" },
|
||||
];
|
||||
|
||||
export function ledgerTheme(themeId?: string) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||
import { useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { apiRequest, ApiError } from "../data/api";
|
||||
import { findCategory, findCategoryPath } from "../data/categories";
|
||||
import { entryTypes, findCategory, findCategoryPath } from "../data/categories";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
|
@ -179,6 +179,10 @@ function categoryLabel(entry: LedgerEntry) {
|
|||
return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入");
|
||||
}
|
||||
|
||||
function categoryIcon(entry: LedgerEntry) {
|
||||
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((type) => type.id === entry.type)?.icon ?? BookOpen;
|
||||
}
|
||||
|
||||
function openParticipantSheet() {
|
||||
participantOpen.value = true;
|
||||
void ledgerStore.loadCurrentMembers();
|
||||
|
|
@ -342,7 +346,7 @@ async function shareLedger() {
|
|||
background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
|
||||
}"
|
||||
>
|
||||
<component :is="findCategory(entry.categoryId)?.icon ?? BookOpen" :size="21" />
|
||||
<component :is="categoryIcon(entry)" :size="21" />
|
||||
</div>
|
||||
<div class="entry-copy">
|
||||
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
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 { useRoute, useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { ApiError } from "../data/api";
|
||||
import { ledgerTheme, ledgerThemes } from "../data/ledgers";
|
||||
|
|
@ -10,6 +10,7 @@ import { useEntryStore } from "../stores/entries";
|
|||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const entryStore = useEntryStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const createOpen = ref(false);
|
||||
|
|
@ -33,7 +34,8 @@ onMounted(async () => {
|
|||
|
||||
async function selectLedger(ledgerId: string) {
|
||||
await ledgerStore.setCurrentLedger(ledgerId);
|
||||
await router.push("/");
|
||||
const returnTo = route.query.returnTo === "/stats" ? "/stats" : "/";
|
||||
await router.push(returnTo);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
|
|
|
|||
|
|
@ -5,35 +5,68 @@ import { computed, onMounted, ref } from "vue";
|
|||
import { useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { findCategory } from "../data/categories";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { ledgerTheme } from "../data/ledgers";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
type StatsTab = "flow" | "category" | "member";
|
||||
type StatsScope = "ledger" | "personal";
|
||||
type RangeMode = "month" | "year" | "custom";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const entryStore = useEntryStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const activeTab = ref<StatsTab>("flow");
|
||||
const scope = ref<StatsScope>("ledger");
|
||||
const rangeMode = ref<RangeMode>("month");
|
||||
const now = new Date();
|
||||
const selectedMonth = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
||||
const selectedYear = ref(now.getFullYear());
|
||||
const customStart = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`);
|
||||
const customEnd = ref(toDateInput(now));
|
||||
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
const yearOptions = computed(() => {
|
||||
const years = new Set<number>();
|
||||
for (let year = now.getFullYear() + 1; year >= now.getFullYear() - 5; year -= 1) years.add(year);
|
||||
for (const entry of entryStore.entries) years.add(new Date(entry.occurredAt).getFullYear());
|
||||
return [...years].sort((left, right) => right - left);
|
||||
});
|
||||
|
||||
const rangeBounds = computed(() => {
|
||||
if (rangeMode.value === "month") {
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
||||
if (!match) return null;
|
||||
const start = new Date(Number(match[1]), Number(match[2]) - 1, 1);
|
||||
return { start, end: new Date(start.getFullYear(), start.getMonth() + 1, 1) };
|
||||
}
|
||||
if (rangeMode.value === "year") {
|
||||
return { start: new Date(selectedYear.value, 0, 1), end: new Date(selectedYear.value + 1, 0, 1) };
|
||||
}
|
||||
const start = fromDateInput(customStart.value);
|
||||
const selectedEnd = fromDateInput(customEnd.value);
|
||||
if (!start || !selectedEnd || start > selectedEnd) return null;
|
||||
const end = new Date(selectedEnd);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const rangeLabel = computed(() => {
|
||||
if (rangeMode.value === "month") {
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(selectedMonth.value);
|
||||
return match ? `${match[1]}年${Number(match[2])}月` : "所选月份";
|
||||
}
|
||||
if (rangeMode.value === "year") return `${selectedYear.value}年`;
|
||||
if (!rangeBounds.value) return "自定义日期";
|
||||
return `${formatDateLabel(customStart.value)} 至 ${formatDateLabel(customEnd.value)}`;
|
||||
});
|
||||
|
||||
const entries = computed(() => {
|
||||
const current = new Date();
|
||||
const bounds = rangeBounds.value;
|
||||
if (!bounds) return [];
|
||||
return entryStore.entries.filter((entry) => {
|
||||
const occurredAt = new Date(entry.occurredAt);
|
||||
const inScope = scope.value === "personal"
|
||||
? entry.ownerId === authStore.user?.id
|
||||
: entry.ledgerIds.includes(ledgerStore.currentLedgerId);
|
||||
return inScope
|
||||
&& occurredAt.getFullYear() === current.getFullYear()
|
||||
&& occurredAt.getMonth() === current.getMonth();
|
||||
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
|
||||
&& occurredAt >= bounds.start
|
||||
&& occurredAt < bounds.end;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -43,10 +76,11 @@ const dailyStats = computed(() => {
|
|||
const groups = new Map<string, LedgerEntry[]>();
|
||||
for (const entry of entries.value) {
|
||||
const date = new Date(entry.occurredAt);
|
||||
const key = `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
const key = toDateInput(date);
|
||||
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
||||
}
|
||||
return Array.from(groups, ([label, items]) => ({ label, ...summarize(items) })).reverse();
|
||||
return Array.from(groups, ([date, items]) => ({ date, label: dailyLabel(date), ...summarize(items) }))
|
||||
.sort((left, right) => right.date.localeCompare(left.date));
|
||||
});
|
||||
|
||||
const maxDailyAmount = computed(() =>
|
||||
|
|
@ -99,33 +133,65 @@ function summarize(items: LedgerEntry[]) {
|
|||
function money(value: number) {
|
||||
return (value / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function toDateInput(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fromDateInput(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string) {
|
||||
const date = fromDateInput(value);
|
||||
return date ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : value;
|
||||
}
|
||||
|
||||
function dailyLabel(value: string) {
|
||||
const date = fromDateInput(value);
|
||||
if (!date) return value;
|
||||
const crossesYear = rangeBounds.value && rangeBounds.value.start.getFullYear() !== new Date(rangeBounds.value.end.getTime() - 1).getFullYear();
|
||||
return crossesYear ? `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` : `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell stats-shell">
|
||||
<header class="stats-header">
|
||||
<header class="stats-header" :style="{ '--ledger-gradient': currentTheme.gradient }">
|
||||
<strong>统计</strong>
|
||||
<button type="button" @click="router.push('/ledgers')">
|
||||
<button type="button" @click="router.push({ path: '/ledgers', query: { returnTo: '/stats' } })">
|
||||
{{ ledgerStore.currentLedger?.name ?? "选择账本" }}
|
||||
<ChevronDown :size="15" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="stats-scroll">
|
||||
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button>
|
||||
|
||||
<div class="stats-scope" role="group" aria-label="统计范围">
|
||||
<button type="button" :class="{ active: scope === 'ledger' }" @click="scope = 'ledger'">当前账本</button>
|
||||
<button type="button" :class="{ active: scope === 'personal' }" @click="scope = 'personal'">我的全部</button>
|
||||
<section class="stats-range" aria-label="统计时间范围">
|
||||
<div class="stats-range-modes" role="group" aria-label="时间范围类型">
|
||||
<button type="button" :class="{ active: rangeMode === 'month' }" @click="rangeMode = 'month'">月</button>
|
||||
<button type="button" :class="{ active: rangeMode === 'year' }" @click="rangeMode = 'year'">年</button>
|
||||
<button type="button" :class="{ active: rangeMode === 'custom' }" @click="rangeMode = 'custom'">自定义</button>
|
||||
</div>
|
||||
<input v-if="rangeMode === 'month'" v-model="selectedMonth" type="month" aria-label="选择月份" />
|
||||
<select v-else-if="rangeMode === 'year'" v-model="selectedYear" aria-label="选择年份">
|
||||
<option v-for="year in yearOptions" :key="year" :value="year">{{ year }}年</option>
|
||||
</select>
|
||||
<div v-else class="stats-custom-range">
|
||||
<label><span>开始</span><input v-model="customStart" type="date" aria-label="开始日期" :max="customEnd || undefined" /></label>
|
||||
<label><span>结束</span><input v-model="customEnd" type="date" aria-label="结束日期" :min="customStart || undefined" /></label>
|
||||
</div>
|
||||
<p v-if="rangeMode === 'custom' && !rangeBounds" role="alert">开始日期不能晚于结束日期</p>
|
||||
</section>
|
||||
|
||||
<section class="stats-summary" aria-label="本月统计">
|
||||
<div>
|
||||
<section class="stats-summary" :aria-label="`${rangeLabel}统计`">
|
||||
<div class="balance">
|
||||
<span>结余</span>
|
||||
<strong>¥ {{ money(totals.income - totals.expense) }}</strong>
|
||||
</div>
|
||||
<div class="income"><TrendingUp :size="18" /><span>收入</span><strong>{{ money(totals.income) }}</strong></div>
|
||||
<div class="expense"><TrendingDown :size="18" /><span>支出</span><strong>{{ money(totals.expense) }}</strong></div>
|
||||
<div class="income"><span><TrendingUp :size="17" />收入</span><strong>{{ money(totals.income) }}</strong></div>
|
||||
<div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="stats-tabs" role="tablist" aria-label="统计维度">
|
||||
|
|
@ -136,7 +202,7 @@ function money(value: number) {
|
|||
|
||||
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
|
||||
<header><strong>每日收支</strong><span>{{ entries.length }} 笔</span></header>
|
||||
<div v-for="day in dailyStats" :key="day.label" class="daily-stat-row">
|
||||
<div v-for="day in dailyStats" :key="day.date" class="daily-stat-row">
|
||||
<span>{{ day.label }}</span>
|
||||
<div class="daily-bars">
|
||||
<i class="income" :style="{ width: `${(day.income / maxDailyAmount) * 100}%` }"></i>
|
||||
|
|
@ -172,7 +238,7 @@ function money(value: number) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!entries.length" class="stats-empty">当前月份还没有流水</div>
|
||||
<div v-if="rangeBounds && !entries.length" class="stats-empty">{{ rangeLabel }}还没有流水</div>
|
||||
</div>
|
||||
|
||||
<QuickEntryHost />
|
||||
|
|
@ -181,20 +247,26 @@ function money(value: number) {
|
|||
|
||||
<style scoped>
|
||||
.stats-shell { background: #f5f8f7; }
|
||||
.stats-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 8px; background: #087f72; color: #fff; }
|
||||
.stats-header { height:74px; display:flex; align-items:center; justify-content:space-between; padding:14px 18px 8px; background:var(--ledger-gradient, #087f72); color:#fff; }
|
||||
.stats-header > strong { font-size: 20px; }
|
||||
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
|
||||
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
|
||||
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; }
|
||||
.stats-scope { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:0 0 12px; border-radius:8px; padding:4px; background:#e7eeec; }
|
||||
.stats-scope button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
|
||||
.stats-scope button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
|
||||
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; }
|
||||
.stats-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
|
||||
.stats-summary > div:first-child { border-left: 0; }
|
||||
.stats-summary span { color: #788581; font-size: 11px; }
|
||||
.stats-summary strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stats-summary > div:first-child strong { font-size: 19px; }
|
||||
.stats-range { display:grid; gap:10px; padding:12px 0; }
|
||||
.stats-range-modes { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; border-radius:8px; padding:4px; background:#e7eeec; }
|
||||
.stats-range-modes button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
|
||||
.stats-range-modes button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
|
||||
.stats-range > input,.stats-range > select,.stats-custom-range input { width:100%; min-width:0; height:40px; border:1px solid #d2dfdc; border-radius:7px; padding:0 10px; background:#fff; color:#344640; font-size:13px; }
|
||||
.stats-custom-range { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.stats-custom-range label { min-width:0; display:grid; gap:4px; }
|
||||
.stats-custom-range label > span { color:#7b8884; font-size:10px; }
|
||||
.stats-range > p { margin:0; color:#c74d39; font-size:11px; }
|
||||
.stats-summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; background:#fff; }
|
||||
.stats-summary > div { min-width:0; display:grid; gap:5px; padding:12px; }
|
||||
.stats-summary .balance { grid-column:1 / -1; border-bottom:1px solid #e1ebe8; padding-top:15px; padding-bottom:15px; }
|
||||
.stats-summary > div:last-child { border-left:1px solid #e1ebe8; }
|
||||
.stats-summary span { display:flex; align-items:center; gap:5px; color:#788581; font-size:11px; }
|
||||
.stats-summary strong { min-width:0; font-size:15px; line-height:1.25; overflow-wrap:anywhere; font-variant-numeric:tabular-nums; }
|
||||
.stats-summary .balance strong { font-size:24px; }
|
||||
.stats-summary .income { color: #0d8b67; }
|
||||
.stats-summary .expense { color: #d84c36; }
|
||||
.stats-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
|
||||
|
|
@ -233,5 +305,5 @@ function money(value: number) {
|
|||
.member-stat-row small.income { color: #0d8b67; }
|
||||
.member-stat-row small.expense { color: #d84c36; }
|
||||
.stats-empty { min-height: 180px; display: grid; place-items: center; color: #7b8884; font-size: 13px; }
|
||||
@media (max-width:360px) { .stats-scroll { padding-right: 12px; padding-left: 12px; } .stats-summary > div { padding: 0 8px; } .stats-summary > div:first-child strong { font-size: 17px; } }
|
||||
@media (max-width:360px) { .stats-scroll { padding-right:12px; padding-left:12px; } .stats-custom-range { grid-template-columns:1fr; } .stats-custom-range input { padding:0 8px; font-size:12px; } .stats-summary > div { padding-right:10px; padding-left:10px; } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ export const ledgerThemeAccents = {
|
|||
berry: "#a84378",
|
||||
meadow: "#2f8060",
|
||||
graphite: "#526a78",
|
||||
sunrise: "#b45f4a",
|
||||
lagoon: "#177f8f",
|
||||
iris: "#7061b5",
|
||||
sky: "#327ba0",
|
||||
olive: "#64784a",
|
||||
midnight: "#47577f",
|
||||
} as const;
|
||||
|
||||
export type LedgerThemeId = keyof typeof ledgerThemeAccents;
|
||||
|
|
|
|||
Loading…
Reference in New Issue